feat: Version 1.2.0

This commit is contained in:
junchi.zhang
2025-12-15 11:11:33 +08:00
parent b3977471c6
commit 4d12afbdfd
11 changed files with 632 additions and 14 deletions
Vendored
BIN
View File
Binary file not shown.
+192 -3
View File
@@ -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 <name>
pinme upload /path/to/file-or-directory -d <name>
```
### 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 <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 <name>]
```
**Options:**
- `path`: Path to the file or directory to upload (optional, if not provided, interactive mode will be entered)
- `-d, --domain <name>`: 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: `<address>-<jwt>`
- 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: `<address>-<jwt>`
**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)
---
+79
View File
@@ -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 <path> --domain <name>
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<void> {
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}`));
}
}
+24 -1
View File
@@ -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 <name>', '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 <path> --domain <name>');
console.log(' $ pinme rm <hash>');
console.log(' $ pinme set-appkey <AppKey>');
console.log(' $ pinme my-domains');
console.log(' $ pinme domain');
console.log(' $ pinme list -l 5');
console.log(' $ pinme ls');
console.log(' $ pinme help');
+32
View File
@@ -0,0 +1,32 @@
import chalk from 'chalk';
import dayjs from 'dayjs';
import { getMyDomains } from './utils/pinmeApi';
export default async function myDomainsCmd(): Promise<void> {
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}`));
}
}
+40
View File
@@ -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<void> {
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}`));
}
}
+73 -6
View File
@@ -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<void> => {
try {
console.log(
@@ -66,6 +89,7 @@ export default async (options?: UploadOptions): Promise<void> => {
// 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<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'}`));
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<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}`));
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<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'}`));
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<void> => {
);
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) {
+63
View File
@@ -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: "<address>-<jwt>"
// Split only at the first '-' to preserve '-' inside JWT if any.
const firstDash = combined.indexOf('-');
if (firstDash <= 0 || firstDash === combined.length - 1) {
throw new Error('Invalid token format. Expected "<address>-<jwt>".');
}
const address = combined.slice(0, firstDash).trim();
const token = combined.slice(firstDash + 1).trim();
if (!address || !token) {
throw new Error('Invalid token content. Address or token is empty.');
}
return { address, token };
}
export function setAuthToken(combined: string): AuthConfig {
ensureConfigDir();
const auth = parseCombinedToken(combined);
fs.writeJsonSync(AUTH_FILE, auth, { spaces: 2 });
return auth;
}
export function getAuthConfig(): AuthConfig | null {
try {
if (!fs.existsSync(AUTH_FILE)) return null;
const data = fs.readJsonSync(AUTH_FILE) as AuthConfig;
if (!data?.address || !data?.token) return null;
return data;
} catch {
return null;
}
}
export function getAuthHeaders(): Record<string, string> {
const conf = getAuthConfig();
if (!conf) {
throw new Error('Auth not set. Run: pinme set-appkey <AppKey>');
}
return {
'token-address': conf.address,
'authentication-tokens': conf.token,
};
}
+32 -3
View File
@@ -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;
}
};
+96
View File
@@ -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<boolean> {
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<CheckDomainResult> {
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<boolean> {
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<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[];
}
if (data?.data?.list && Array.isArray(data.data.list)) {
// fallback: sometimes wrapped in { list: [] }
return data.data.list as MyDomainItem[];
}
}
return [];
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pinme",
"version": "1.1.5",
"version": "1.2.0",
"publishConfig": {
"access": "public"
},