diff --git a/.DS_Store b/.DS_Store index 5560db2..3a7a116 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/README.md b/README.md index 3023c1e..85b0199 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ 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 ``` ### Remove files from IPFS @@ -70,6 +74,26 @@ pinme list -l 5 pinme list -c ``` +### Set AppKey for authentication + +```bash +# Interactive AppKey setup +pinme set-appkey + +# Set AppKey directly +pinme set-appkey +``` + +### View your domains + +```bash +# List all domains owned by current account +pinme my-domains + +# Or use the shorthand command +pinme domain +``` + ### Get help ```bash @@ -81,14 +105,15 @@ pinme help ### `upload` -Upload a file or directory to the IPFS network. +Upload a file or directory to the IPFS network. Supports binding to a Pinme subdomain after upload. ```bash -pinme upload [path] +pinme upload [path] [--domain ] ``` **Options:** - `path`: Path to the file or directory to upload (optional, if not provided, interactive mode will be entered) +- `-d, --domain `: Pinme subdomain to bind after upload (optional) **Examples:** ```bash @@ -100,6 +125,10 @@ pinme upload ./example.jpg # Upload an entire directory pinme upload ./my-website + +# Upload and bind to a domain +pinme upload ./my-website --domain my-site +pinme upload ./my-website -d my-site ``` ### `rm` @@ -149,6 +178,52 @@ pinme ls -l 5 pinme list -c ``` +### `set-appkey` + +Set AppKey for authentication and automatically merge anonymous upload history to the current account. + +```bash +pinme set-appkey [AppKey] +``` + +**Options:** +- `AppKey`: Your AppKey for authentication (optional, if not provided, interactive mode will be entered) + +**Examples:** +```bash +# Interactive AppKey setup +pinme set-appkey + +# Set AppKey directly +pinme set-appkey your-app-key-here +``` + +**Note:** After setting the AppKey, your anonymous upload history will be automatically merged to your account. + +### `my-domains` / `domain` + +List all domains owned by the current account. + +```bash +pinme my-domains +pinme domain +``` + +**Examples:** +```bash +# List all domains +pinme my-domains + +# Shorthand command +pinme domain +``` + +This command displays information about each domain including: +- Domain name +- Domain type +- Bind time +- Expire time + ### `help` Display help information. @@ -205,11 +280,125 @@ export default { } ``` +## GitHub Actions Integration + +PinMe can be integrated with GitHub Actions to automatically deploy your project when you push code to GitHub. This enables a fully automated CI/CD workflow. + +### Quick Setup + +1. **Add the workflow file** to your repository: + - Copy `.github/workflows/deploy.yml` from the PinMe repository to your project + - Or create `.github/workflows/deploy.yml` in your repository + +2. **Configure GitHub Secrets**: + - Go to your repository → Settings → Secrets and variables → Actions + - Add a new secret named `PINME_APPKEY` with your PinMe AppKey + - (Optional) Add `PINME_DOMAIN` to specify a custom domain name + +3. **Push to trigger deployment**: + - Push code to `main` or `master` branch to trigger automatic deployment + - Or manually trigger via Actions tab → "Deploy to PinMe" → Run workflow + +### Workflow Features + +The GitHub Actions workflow automatically: + +- ✅ Detects and installs project dependencies +- ✅ Builds your project (if a build script exists) +- ✅ Installs PinMe CLI +- ✅ Sets up authentication using your AppKey +- ✅ Auto-detects build output directory (`dist`, `build`, `public`, or `out`) +- ✅ Uploads to IPFS and binds to your domain +- ✅ Provides deployment summary with access URL + +### Configuration Options + +#### Using GitHub Secrets + +You can configure the following secrets in your repository: + +- **`PINME_APPKEY`** (Required): Your PinMe AppKey for authentication + - Format: `
-` + - Get your AppKey from [PinMe website](https://pinme.eth.limo/) + +- **`PINME_DOMAIN`** (Optional): Default domain name to bind + - If not set, the workflow will generate a domain from your repository name + - Example: `my-awesome-project` → `https://my-awesome-project.pinit.eth.limo` + +#### Manual Workflow Dispatch + +You can also manually trigger the workflow with custom parameters: + +1. Go to Actions tab in your repository +2. Select "Deploy to PinMe" workflow +3. Click "Run workflow" +4. Enter: + - **Domain**: Your desired PinMe domain name + - **Build Directory**: Your build output directory (default: `dist`) + +### Example Workflow + +```yaml +name: Deploy to PinMe + +on: + push: + branches: [main, master] + workflow_dispatch: + inputs: + domain: + description: 'PinMe domain name' + required: true + build_dir: + description: 'Build directory' + default: 'dist' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '18' + - run: npm ci + - run: npm run build + - run: npm install -g pinme + - run: pinme set-appkey "${{ secrets.PINME_APPKEY }}" + - run: pinme upload dist --domain "${{ secrets.PINME_DOMAIN }}" +``` + +### Supported Build Tools + +The workflow automatically detects and supports: + +- **Vite**: Builds to `dist/` +- **Create React App**: Builds to `build/` +- **Next.js**: Builds to `out/` (with `output: 'export'`) +- **Vue CLI**: Builds to `dist/` +- **Angular**: Builds to `dist/` +- **Static sites**: Uses root directory or `public/` + +### Troubleshooting + +**Build directory not found:** +- Ensure your build script outputs to a standard directory (`dist`, `build`, `public`, or `out`) +- Or set `PINME_DOMAIN` secret and use manual workflow dispatch to specify custom directory + +**Authentication failed:** +- Verify your `PINME_APPKEY` secret is correctly set +- Ensure the AppKey format is correct: `
-` + +**Domain binding failed:** +- Check if the domain name is available +- Ensure you have permission to bind the domain +- Try a different domain name + ## Contact Us If you have questions or suggestions, please contact us through: -- GitHub Issues: [https://github.com/glitternetwork/pinme/issues](https://github.com/glitternetwork/pinme/issue) +- GitHub Issues: [https://github.com/glitternetwork/pinme/issues](https://github.com/glitternetwork/pinme/issues) - Email: [pinme@glitterprotocol.io](mailto:pinme@glitterprotocol.io) --- diff --git a/bin/bind.ts b/bin/bind.ts new file mode 100644 index 0000000..2730d42 --- /dev/null +++ b/bin/bind.ts @@ -0,0 +1,79 @@ +import path from 'path'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import upload from './utils/uploadToIpfsSplit'; +import { checkDomainAvailable, bindPinmeDomain } from './utils/pinmeApi'; + +interface Args { + domain?: string; + targetPath?: string; +} + +function parseArgs(): Args { + // Usage: pinme bind --domain + const args = process.argv.slice(2); + const res: Args = {}; + const idx = args.indexOf('bind'); + if (idx >= 0) { + const maybePath = args[idx + 1]; + if (maybePath && !maybePath.startsWith('-')) res.targetPath = maybePath; + } + const dIdx = args.findIndex((a) => a === '--domain' || a === '-d'); + if (dIdx >= 0 && args[dIdx + 1]) { + res.domain = args[dIdx + 1]; + } + return res; +} + +export default async function bindCmd(): Promise { + try { + let { domain, targetPath } = parseArgs(); + if (!targetPath) { + const ans = await inquirer.prompt([ + { type: 'input', name: 'path', message: 'Enter the path to upload and bind: ' }, + ]); + targetPath = ans.path; + } + if (!domain) { + const ans = await inquirer.prompt([ + { type: 'input', name: 'domain', message: 'Enter the Pinme subdomain (e.g., test_abc): ' }, + ]); + domain = ans.domain?.trim(); + } + if (!targetPath || !domain) { + console.log(chalk.red('Missing parameters. Path and domain are required.')); + return; + } + + // 前置校验域名 + const check = await checkDomainAvailable(domain); + if (!check.is_valid) { + console.log(chalk.red(`Domain not available: ${check.error || 'unknown reason'}`)); + return; + } + console.log(chalk.green(`Domain available: ${domain}`)); + + // 上传 + const absolutePath = path.resolve(targetPath); + console.log(chalk.blue(`Uploading: ${absolutePath}`)); + const up = await upload(absolutePath); + if (!up?.contentHash) { + console.log(chalk.red('Upload failed, binding aborted.')); + return; + } + console.log(chalk.green(`Upload success, CID: ${up.contentHash}`)); + + // 绑定 + const ok = await bindPinmeDomain(domain, up.contentHash); + if (!ok) { + console.log(chalk.red('Binding failed. Please try again later.')); + return; + } + 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 0414c44..4850afd 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -13,6 +13,8 @@ import { version } from '../package.json'; import upload from './upload'; import remove from './remove'; import { displayUploadHistory, clearUploadHistory } from './utils/history'; +import setAppKeyCmd from './set-appkey'; +import myDomainsCmd from './my-domains'; // display the ASCII art logo function showBanner(): void { @@ -33,7 +35,8 @@ program program .command('upload') - .description("upload a file or directory to IPFS") + .description("upload a file or directory to IPFS. Supports --domain to bind after upload") + .option('-d, --domain ', 'Pinme subdomain') .action(() => upload()); program @@ -41,6 +44,22 @@ program .description("remove a file from IPFS network") .action(() => remove()); +program + .command('set-appkey') + .description("Set AppKey for authentication, and auto-merge anonymous history") + .action(() => setAppKeyCmd()); + +program + .command('my-domains') + .alias('domain') + .description("List domains owned by current account") + .action(() => myDomainsCmd()); + +program + .command('domain') + .description("Alias for 'my-domains' command") + .action(() => myDomainsCmd()); + program .command('list') .description("show upload history") @@ -82,7 +101,11 @@ program.on('--help', () => { console.log(''); console.log('Examples:'); console.log(' $ pinme upload'); + console.log(' $ pinme upload --domain '); console.log(' $ pinme rm '); + console.log(' $ pinme set-appkey '); + console.log(' $ pinme my-domains'); + console.log(' $ pinme domain'); console.log(' $ pinme list -l 5'); console.log(' $ pinme ls'); console.log(' $ pinme help'); diff --git a/bin/my-domains.ts b/bin/my-domains.ts new file mode 100644 index 0000000..4b45621 --- /dev/null +++ b/bin/my-domains.ts @@ -0,0 +1,32 @@ +import chalk from 'chalk'; +import dayjs from 'dayjs'; +import { getMyDomains } from './utils/pinmeApi'; + +export default async function myDomainsCmd(): Promise { + try { + const list = await getMyDomains(); + if (!list.length) { + console.log(chalk.yellow('No bound domains found.')); + return; + } + + console.log(chalk.cyan('My domains:')); + console.log(chalk.cyan('-'.repeat(80))); + list.forEach((item, i) => { + console.log(chalk.green(`${i + 1}. ${item.domain_name}`)); + console.log(chalk.white(` Type: ${item.domain_type}`)); + if (item.bind_time) { + console.log(chalk.white(` Bind time: ${dayjs(item.bind_time * 1000).format('YYYY-MM-DD HH:mm:ss')}`)); + } + if (typeof item.expire_time === 'number') { + const label = item.expire_time === 0 ? 'Never' : dayjs(item.expire_time * 1000).format('YYYY-MM-DD HH:mm:ss'); + console.log(chalk.white(` Expire time: ${label}`)); + } + console.log(chalk.cyan('-'.repeat(80))); + }); + } catch (e: any) { + console.log(chalk.red(`Failed to fetch domains: ${e?.message || e}`)); + } +} + + diff --git a/bin/set-appkey.ts b/bin/set-appkey.ts new file mode 100644 index 0000000..6067524 --- /dev/null +++ b/bin/set-appkey.ts @@ -0,0 +1,40 @@ +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { setAuthToken } from './utils/auth'; +import { getDeviceId } from './utils/getDeviceId'; +import { bindAnonymousDevice } from './utils/pinmeApi'; + +export default async function setAppKeyCmd(): Promise { + try { + const argAppKey = process.argv[3]; + let appKey = argAppKey; + if (!appKey) { + const ans = await inquirer.prompt([ + { + type: 'input', + name: 'appKey', + message: 'Enter AppKey: ', + }, + ]); + appKey = ans.appKey; + } + if (!appKey) { + console.log(chalk.red('AppKey not provided.')); + return; + } + const saved = setAuthToken(appKey); + console.log(chalk.green(`Auth set for address: ${saved.address}`)); + + // Auto-merge anonymous history + const deviceId = getDeviceId(); + const ok = await bindAnonymousDevice(deviceId); + if (ok) { + console.log(chalk.green('Anonymous history merged to current account.')); + } else { + console.log(chalk.yellow('Anonymous history merge not confirmed. You may retry later.')); + } + } catch (e: any) { + console.log(chalk.red(`Failed to set AppKey: ${e?.message || e}`)); + } +} + diff --git a/bin/upload.ts b/bin/upload.ts index bb5f5d2..ea9c9e4 100644 --- a/bin/upload.ts +++ b/bin/upload.ts @@ -5,6 +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 { getAuthConfig } from './utils/auth'; +import { getDeviceId } from './utils/getDeviceId'; // get from environment variables const URL = process.env.IPFS_PREVIEW_URL; @@ -13,13 +16,15 @@ const secretKey = process.env.SECRET_KEY; import { checkNodeVersion } from './utils/checkNodeVersion'; checkNodeVersion(); -// encrypt the hash -function encryptHash(hash: string, key: string | undefined): string { +// encrypt the hash with optional uid (device id) +function encryptHash(contentHash: string, key: string | undefined, uid?: string): string { try { if (!key) { throw new Error('Secret key not found'); } - const encrypted = CryptoJS.RC4.encrypt(hash, key).toString(); + // Combine contentHash-uid if uid exists, otherwise just contentHash (for backward compatibility) + const combined = uid ? `${contentHash}-${uid}` : contentHash; + const encrypted = CryptoJS.RC4.encrypt(combined, key).toString(); const urlSafe = encrypted .replace(/\+/g, '-') .replace(/\//g, '_') @@ -27,7 +32,7 @@ function encryptHash(hash: string, key: string | undefined): string { return urlSafe; } catch (error: any) { console.error(`Encryption error: ${error.message}`); - return hash; + return contentHash; } } @@ -52,6 +57,24 @@ interface UploadOptions { [key: string]: any; } +function getDomainFromArgs(): string | null { + const args = process.argv.slice(2); + const dIdx = args.findIndex((a) => a === '--domain' || a === '-d'); + if (dIdx >= 0 && args[dIdx + 1] && !args[dIdx + 1].startsWith('-')) { + return String(args[dIdx + 1]).trim(); + } + return null; +} + +// Get uid: use address from auth if logged in, otherwise use deviceId +function getUid(): string { + const auth = getAuthConfig(); + if (auth?.address) { + return auth.address; + } + return getDeviceId(); +} + export default async (options?: UploadOptions): Promise => { try { console.log( @@ -66,6 +89,7 @@ export default async (options?: UploadOptions): Promise => { // if the parameter is passed, upload directly, pinme upload /path/to/dir const argPath = process.argv[3]; + const domainArg = getDomainFromArgs(); if (argPath && !argPath.startsWith('-')) { // use the synchronous path check function @@ -75,11 +99,22 @@ 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'}`)); + return; + } + console.log(chalk.green(`Domain available: ${domainArg}`)); + } + console.log(chalk.blue(`uploading ${absolutePath} to ipfs...`)); try { const result = await upload(absolutePath); if (result) { - const encryptedCID = encryptHash(result.contentHash, secretKey); + const uid = getUid(); + const encryptedCID = encryptHash(result.contentHash, secretKey, uid); console.log( chalk.cyan( figlet.textSync('Successful', { horizontalLayout: 'full' }), @@ -87,6 +122,17 @@ 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}`)); + 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.')); + } + } console.log(chalk.green('\n🎉 upload successful, program exit')); } } catch (error: any) { @@ -111,12 +157,23 @@ 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'}`)); + return; + } + console.log(chalk.green(`Domain available: ${domainArg}`)); + } + console.log(chalk.blue(`uploading ${absolutePath} to ipfs...`)); try { const result = await upload(absolutePath); if (result) { - const encryptedCID = encryptHash(result.contentHash, secretKey); + const uid = getUid(); + const encryptedCID = encryptHash(result.contentHash, secretKey, uid); console.log( chalk.cyan( figlet.textSync('Successful', { horizontalLayout: 'full' }), @@ -124,6 +181,16 @@ export default async (options?: UploadOptions): Promise => { ); console.log(chalk.cyan(`URL:`)); console.log(chalk.cyan(`${URL}${encryptedCID}`)); + if (domainArg) { + console.log(chalk.blue(`Binding domain: ${domainArg} 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.')); + } + } console.log(chalk.green('\n🎉 upload successful, program exit')); } } catch (error: any) { diff --git a/bin/utils/auth.ts b/bin/utils/auth.ts new file mode 100644 index 0000000..ea55d1e --- /dev/null +++ b/bin/utils/auth.ts @@ -0,0 +1,63 @@ +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: "
-" + // 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 "
-".'); + } + 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 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 { + const conf = getAuthConfig(); + if (!conf) { + throw new Error('Auth not set. Run: pinme set-appkey '); + } + return { + 'token-address': conf.address, + 'authentication-tokens': conf.token, + }; +} + + diff --git a/bin/utils/decrypt.ts b/bin/utils/decrypt.ts index dd25198..c7e1ba1 100644 --- a/bin/utils/decrypt.ts +++ b/bin/utils/decrypt.ts @@ -1,6 +1,12 @@ import CryptoJS from "crypto-js"; -export const decryptHash = (encryptedHash: string | undefined, key: string) => { +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; @@ -10,9 +16,32 @@ export const decryptHash = (encryptedHash: string | undefined, key: string) => { base64 += "="; } const decrypted = CryptoJS.RC4.decrypt(base64, key); - return decrypted.toString(CryptoJS.enc.Utf8); + 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 encryptedHash; + // Return as legacy format if decryption fails + return encryptedHash ? { + contentHash: encryptedHash, + version: 1, + } : null; } }; diff --git a/bin/utils/pinmeApi.ts b/bin/utils/pinmeApi.ts new file mode 100644 index 0000000..7e02d73 --- /dev/null +++ b/bin/utils/pinmeApi.ts @@ -0,0 +1,96 @@ +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'; + +function createClient(): AxiosInstance { + const headers = getAuthHeaders(); + return axios.create({ + baseURL: DEFAULT_BASE, + timeout: 20000, + headers: { + ...headers, + Accept: '*/*', + 'Content-Type': 'application/json', + 'User-Agent': 'Pinme-CLI', + Connection: 'keep-alive', + }, + }); +} + +export async function bindAnonymousDevice(anonymousUid: string): Promise { + try { + const client = createClient(); + const { data } = await client.post('/bind_anonymous', { + anonymous_uid: anonymousUid, + }); + return data?.code === 200; + } catch (e: any) { + console.log(chalk.yellow(`Failed to trigger anonymous binding: ${e?.message || e}`)); + return false; + } +} + +export interface CheckDomainResult { + is_valid: boolean; + error?: string; +} + +export async function checkDomainAvailable(domainName: string): Promise { + const client = createClient(); + // 端点可能未固定,优先使用环境变量,其次尝试两个常见路径 + const configured = process.env.PINME_CHECK_DOMAIN_PATH || '/check_domain'; + const fallbacks = [configured, '/check_domain_available']; + + 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 }; + } + // 不符合预期结构,继续尝试下一个路径 + } catch (e: any) { + // 404/405/500 等继续尝试下一个 + } + } + // 如果所有尝试失败,则返回未知状态,交由后续 bind 返回报错提示 + return { is_valid: true }; +} + +export async function bindPinmeDomain(domainName: string, hash: string): Promise { + const client = createClient(); + const { data } = await client.post('/bind_pinme_domain', { + domain_name: domainName, + hash, + }); + return data?.code === 200; +} + +export interface MyDomainItem { + domain_name: string; + domain_type: number; + bind_time: number; + expire_time: number; +} + +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[]; + } + if (data?.data?.list && Array.isArray(data.data.list)) { + // fallback: sometimes wrapped in { list: [] } + return data.data.list as MyDomainItem[]; + } + } + return []; +} + + diff --git a/package.json b/package.json index 7d9529a..57ef129 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinme", - "version": "1.1.5", + "version": "1.2.0", "publishConfig": { "access": "public" },