chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:28 +08:00
commit ba73d7ef36
160 changed files with 37714 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
IPFS_API_URL=https://example.com
IPFS_PREVIEW_URL=https://example.com
PINME_TEMPLATE_BRANCH=main
SECRET_KEY=your-secret-key
FILE_SIZE_LIMIT=100 # MB
DIRECTORY_SIZE_LIMIT=500 # MB
+28
View File
@@ -0,0 +1,28 @@
module.exports = {
root: true,
env: {
es2022: true,
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
extends: ['prettier'],
ignorePatterns: [
'dist/',
'node_modules/',
'coverage/',
'reports/',
'.stryker-tmp/',
],
rules: {},
overrides: [
{
files: ['*.mjs'],
parser: 'espree',
},
],
};
+78
View File
@@ -0,0 +1,78 @@
name: CI
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * 1'
pull_request:
push:
branches:
- main
- master
jobs:
verify:
name: Verify on Node ${{ matrix.node-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [16.13.0, 18.x, 20.x]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Verify
run: npm run verify
mutation:
name: Mutation tests
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: npm
- name: Install dependencies
run: npm ci
- name: Run mutation tests
run: npm run test:mutation
audit:
name: Dependency audit
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: npm
- name: Install dependencies
run: npm ci
- name: Audit production dependencies
run: npm audit --omit=dev --audit-level=critical
+14
View File
@@ -0,0 +1,14 @@
node_modules
/dist
.DS_Store
.env
/test-output
/temp
/test-repro
.catpaw
.claude/settings.local.json
chrome-test-1
docs/*
coverage/
.stryker-tmp/
reports/mutation/
+10
View File
@@ -0,0 +1,10 @@
node_modules
/bin
/src
.github
.gitignore
.prettierrc
rollup.config.js
tsconfig.json
*.log
.env
+2
View File
@@ -0,0 +1,2 @@
/dist
*.yaml
+17
View File
@@ -0,0 +1,17 @@
module.exports = {
pluginSearchDirs: false,
plugins: [
],
printWidth: 80,
proseWrap: 'never',
singleQuote: true,
trailingComma: 'all',
overrides: [
{
files: '*.md',
options: {
proseWrap: 'preserve',
},
},
],
};
+51
View File
@@ -0,0 +1,51 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
PinMe is a zero-config CLI tool for deploying static sites to IPFS. Built with TypeScript, bundled with esbuild, published to npm as `pinme`. Users run commands like `pinme upload dist` to deploy frontends.
## Build & Dev
```bash
npm run build # Production build (esbuild → dist/index.js)
npm run dev # Dev build
npm run test # Unit/integration tests (Vitest)
npm run test:cli # Real CLI black-box tests against dist/index.js
npm run verify # Full PR gate: lint, typecheck, tests, build, CLI, pack
npm run test:mutation # Slow mutation tests (manual/nightly)
```
Build uses `build.js` (esbuild), NOT `rollup.config.js` (legacy, unused). esbuild reads `.env` via dotenv at build time and injects env vars as `process.env.*` defines.
The output is a single CJS file at `dist/index.js` with a shebang, used as the `pinme` CLI binary.
## Architecture
- `bin/index.ts` — CLI entry point, uses `commander` to register all commands
- `bin/*.ts` — Individual command implementations (upload, save, create, bind, importCar, exportCar, delete, etc.)
- `bin/utils/` — Shared utilities:
- `config.ts``APP_CONFIG` singleton, all API base URLs and tuning knobs from env vars
- `apiClient.ts` — Axios client factory (`createPinmeApiClient`, `createCarApiClient`)
- `pinmeApi.ts` — High-level API wrappers (domain binding, wallet, CAR export, etc.)
- `uploadToIpfs.ts` / `uploadToIpfsSplit.ts` — IPFS upload logic (single file vs chunked)
- `webLogin.ts` — Auth token management (reads from `~/.pinme/`)
- `domainValidator.ts` — Domain name validation and DNS vs subdomain detection
- `cliError.ts` — Structured CLI error types
- `bin/services/uploadService.ts` — Upload orchestration (hash encryption, URL generation)
- `skills/` — Codex skill definitions for this project
## Key Patterns
- Auth: AppKey stored locally at `~/.pinme/`. Auth headers injected via `getAuthHeaders()` in `webLogin.ts`.
- API clients: Always use `createPinmeApiClient()` or `createCarApiClient()` from `apiClient.ts`, never raw axios.
- Token expiry: `pinmeApi.ts` has centralized token-expired detection (`isTokenExpired`) — all API calls should go through wrappers there.
- Config: All env-driven config lives in `APP_CONFIG` (`config.ts`). Don't read `process.env` directly elsewhere.
- The `save` command reads `pinme.toml` from project root for full-stack deploy (frontend + Cloudflare Worker + D1).
## Code Style
- Prettier: single quotes, trailing commas, 80 char width
- TypeScript with `strict: false`, target ESNext, module ESNext
- CLI output uses `chalk` for colors, `ora` for spinners, `inquirer` for prompts, `figlet` for banner
+21
View File
@@ -0,0 +1,21 @@
# CHANGELOG
## v1.1.2 (2025-08-07)
### Bug Fixes
- Fixed issue where uploaded folders could not be found after upload
### Improvements
- Optimized client-side upload 524 error handling
- Enhanced upload process flow and user experience
## v1.1.0 (2025-06-14)
### New Features
- Added IPFS file removal functionality with support for multiple input formats (hash, URL, subname)
- Enhanced upload progress display with file count and time counter
### Improvements
- Optimized upload timeout configuration for better network connection stability
- Unified command-line interface font display effects
+51
View File
@@ -0,0 +1,51 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
PinMe is a zero-config CLI tool for deploying static sites to IPFS. Built with TypeScript, bundled with esbuild, published to npm as `pinme`. Users run commands like `pinme upload dist` to deploy frontends.
## Build & Dev
```bash
npm run build # Production build (esbuild → dist/index.js)
npm run dev # Dev build
npm run test # Unit/integration tests (Vitest)
npm run test:cli # Real CLI black-box tests against dist/index.js
npm run verify # Full PR gate: lint, typecheck, tests, build, CLI, pack
npm run test:mutation # Slow mutation tests (manual/nightly)
```
Build uses `build.js` (esbuild), NOT `rollup.config.js` (legacy, unused). esbuild reads `.env` via dotenv at build time and injects env vars as `process.env.*` defines.
The output is a single CJS file at `dist/index.js` with a shebang, used as the `pinme` CLI binary.
## Architecture
- `bin/index.ts` — CLI entry point, uses `commander` to register all commands
- `bin/*.ts` — Individual command implementations (upload, save, create, bind, importCar, exportCar, delete, etc.)
- `bin/utils/` — Shared utilities:
- `config.ts``APP_CONFIG` singleton, all API base URLs and tuning knobs from env vars
- `apiClient.ts` — Axios client factory (`createPinmeApiClient`, `createCarApiClient`)
- `pinmeApi.ts` — High-level API wrappers (domain binding, wallet, CAR export, etc.)
- `uploadToIpfs.ts` / `uploadToIpfsSplit.ts` — IPFS upload logic (single file vs chunked)
- `webLogin.ts` — Auth token management (reads from `~/.pinme/`)
- `domainValidator.ts` — Domain name validation and DNS vs subdomain detection
- `cliError.ts` — Structured CLI error types
- `bin/services/uploadService.ts` — Upload orchestration (hash encryption, URL generation)
- `skills/` — Claude Code skill definitions for this project
## Key Patterns
- Auth: AppKey stored locally at `~/.pinme/`. Auth headers injected via `getAuthHeaders()` in `webLogin.ts`.
- API clients: Always use `createPinmeApiClient()` or `createCarApiClient()` from `apiClient.ts`, never raw axios.
- Token expiry: `pinmeApi.ts` has centralized token-expired detection (`isTokenExpired`) — all API calls should go through wrappers there.
- Config: All env-driven config lives in `APP_CONFIG` (`config.ts`). Don't read `process.env` directly elsewhere.
- The `save` command reads `pinme.toml` from project root for full-stack deploy (frontend + Cloudflare Worker + D1).
## Code Style
- Prettier: single quotes, trailing commas, 80 char width
- TypeScript with `strict: false`, target ESNext, module ESNext
- CLI output uses `chalk` for colors, `ora` for spinners, `inquirer` for prompts, `figlet` for banner
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 PINME
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+391
View File
@@ -0,0 +1,391 @@
<p align="center">
<a href="https://pinme.eth.limo/">
<img src="https://2egc5b44.pinit.eth.limo/" height="92" alt="PinMe logo">
<h3 align="center">PinMe</h3>
</a>
</p>
<p align="center">
Create and deploy your web in one command.
</p>
# PinMe
[PinMe](https://pinme.eth.limo/) is a zero-config deployment CLI focused on one-command creation and deployment for full-stack projects.
It lets you quickly set up and launch a complete project with an integrated frontend, Worker backend, and database, without tedious configuration. PinMe is built to make full-stack delivery much simpler and significantly improve development efficiency.
Website: [https://pinme.eth.limo/](https://pinme.eth.limo/)
> **PinMe Skill**
>
> Install the PinMe skill before using PinMe in agent workflows:
>
> ```bash
> npx skills add glitternetwork/pinme
> ```
## Table of Contents
- [Quick Start](#quick-start)
- [For AI Agents](#for-ai-agents)
- [Installation](#installation)
- [PinMe Project Workflow](#pinme-project-workflow)
- [Authentication and Account Commands](#authentication-and-account-commands)
- [Static Uploads and IPFS Utilities](#static-uploads-and-ipfs-utilities)
- [Command Reference](#command-reference)
- [Development and Testing](#development-and-testing)
- [Limits and Operational Notes](#limits-and-operational-notes)
- [Examples](#examples)
- [Support](#support)
## Quick Start
### Prerequisites
- Node.js `>= 16.13.0`
### Create a new Worker project
```bash
npm install -g pinme
pinme login
pinme create my-app
cd my-app
pinme save
```
What this workflow gives you:
- a generated PinMe project from the official template
- platform-side Worker and database provisioning
- local project config in `pinme.toml`
- frontend and Worker deployment from one CLI
### Update only the part you changed
```bash
pinme update-worker
pinme update-db
pinme update-web
```
### Upload a static build when you do not need the project workflow
```bash
pinme login
pinme upload dist
```
Common build directories are `dist`, `build`, `out`, and `public`.
## For AI Agents
Prefer the PinMe project workflow when the user wants a frontend plus backend plus database, or when the repo already contains `pinme.toml`.
### Project-mode protocol
Use this flow when the user wants a Worker app, database migrations, or ongoing project updates.
1. Check Node.js:
```bash
node --version
```
2. Ensure the CLI is available:
```bash
npm install -g pinme
```
3. Authenticate:
```bash
pinme login
```
4. Choose the right project command:
- create a new project: `pinme create <name>`
- deploy everything from a PinMe project root: `pinme save`
- update Worker only: `pinme update-worker`
- update SQL migrations only: `pinme update-db`
- update frontend only: `pinme update-web`
5. If the repo contains `pinme.toml`, run project commands from that directory.
6. Return the final project URL printed by the CLI for frontend deploys. For Worker-only or DB-only updates, return the relevant success result instead of fabricating a URL.
### Static-upload fallback
Use this only when the task is just "publish the built frontend" and there is no PinMe project workflow involved.
1. Authenticate:
```bash
pinme login
```
Or for automation:
```bash
pinme set-appkey <AppKey>
```
2. Find the built output directory in this order:
- `dist/`
- `build/`
- `out/`
- `public/`
3. Verify the directory exists and contains built assets such as `index.html`.
4. Upload it:
```bash
pinme upload <folder>
```
### Guardrails
- Do not upload source folders such as `src/`.
- Do not upload `node_modules`, `.git`, or `.env`.
- Do not claim unsupported backend hosting outside the PinMe project template flow.
- For project commands, do not run `update-*` commands outside a PinMe project root with `pinme.toml`.
## Installation
Install from npm:
```bash
npm install -g pinme
```
Verify installation:
```bash
pinme --version
```
## PinMe Project Workflow
### What `create` sets up
`pinme create <name>` does more than scaffold files. The command:
- requires an authenticated session
- creates the platform project resources first
- downloads the official Worker project template
- writes project metadata into `pinme.toml`
- writes backend metadata and frontend config files
- installs workspace dependencies
- builds the Worker
- uploads Worker code and SQL files
- builds the frontend and attempts an initial frontend upload
After creation, the CLI prints the project management URL and suggests `pinme save` for the next deploy.
### Create a project
```bash
pinme login
pinme create my-app
```
If the target directory already exists, the CLI asks before overwriting it unless `--force` is used.
### Deploy the whole project
Run this from the project root that contains `pinme.toml`:
```bash
pinme save
pinme save --domain my-site
pinme save --domain example.com
```
`save` performs the full deploy path:
- installs project dependencies
- builds the Worker with `npm run build:worker`
- uploads Worker code and SQL files from `db/`
- builds the frontend with `npm run build:frontend`
- uploads `frontend/dist`
- optionally binds a domain after the frontend deploy
### Update only one layer
Use targeted commands when only one part changed:
```bash
pinme update-worker
pinme update-db
pinme update-web
```
What each command expects:
- `update-worker`: builds and uploads Worker code from the current PinMe project
- `update-db`: uploads `.sql` files from `db/`
- `update-web`: builds and uploads `frontend/dist`
### Delete a project
```bash
pinme delete
pinme delete my-app
pinme delete my-app --force
```
This deletes the platform-side Worker, domain binding, and D1 database. Local files remain unchanged.
## Authentication and Account Commands
### Login and AppKey
```bash
pinme login
pinme login --env test
pinme set-appkey
pinme set-appkey <AppKey>
pinme show-appkey
pinme appkey
pinme logout
```
Notes:
- `pinme login` is the recommended path for project commands.
- `set-appkey` is the alternative authentication method for CLI and automation usage.
### Domains, wallet, and history
```bash
pinme my-domains
pinme domain
pinme wallet
pinme wallet-balance
pinme balance
pinme list
pinme ls
pinme list -l 5
pinme list -c
```
## Static Uploads and IPFS Utilities
These commands are useful when you already have artifacts and do not need the full Worker project flow.
### Upload a directory or file
```bash
pinme upload
pinme upload ./dist
pinme upload ./dist --domain my-site
pinme upload ./dist --domain example.com
pinme upload ./dist --domain my-site --dns
```
Domain handling:
- domains containing a dot are treated as DNS domains
- domains without a dot are treated as PinMe subdomains
- `--dns` forces DNS mode
### Bind while uploading
```bash
pinme bind ./dist --domain my-site
pinme bind ./dist --domain example.com
```
`bind` requires wallet balance.
### Import or export CAR files
```bash
pinme import
pinme import ./site.car
pinme import ./site.car --domain my-site
pinme export <cid>
pinme export <cid> --output ./exports
```
### Remove uploaded content
```bash
pinme rm
pinme rm <value>
```
## Command Reference
| Command | What it does |
| --------------------------------------------------------- | ------------------------------------------------------------ |
| `pinme create [name]` | Create a new PinMe Worker project from the official template |
| `pinme save [--domain <name>]` | Deploy the current PinMe project: Worker, SQL, and frontend |
| `pinme update-worker` | Build and upload Worker code only |
| `pinme update-db` | Upload SQL migrations from `db/` only |
| `pinme update-web` | Build and upload the frontend only |
| `pinme delete [name] [--force]` | Delete a platform project |
| `pinme upload [path]` | Upload a file or directory to IPFS |
| `pinme bind [path] --domain <name>` | Upload and bind a domain |
| `pinme import [path]` | Import a CAR file |
| `pinme export <cid> [--output <dir>]` | Export IPFS content as a CAR file |
| `pinme rm [value]` | Remove uploaded content |
| `pinme login [--env test\|prod]` | Login via browser |
| `pinme set-appkey [AppKey]` | Set authentication with an AppKey |
| `pinme show-appkey` / `pinme appkey` | Show masked AppKey info |
| `pinme my-domains` / `pinme domain` | List domains owned by the current account |
| `pinme wallet` / `pinme wallet-balance` / `pinme balance` | Show current wallet balance |
| `pinme list` / `pinme ls` | Show upload history |
| `pinme help` | Show CLI help |
## Development and Testing
PinMe uses Vitest for unit/integration tests, real `dist/index.js` CLI smoke tests, npm package checks, and Stryker for slower mutation testing.
```bash
npm run test # Unit and integration tests
npm run test:coverage # Coverage gate for core modules
npm run test:cli # Real CLI black-box tests
npm run test:pack # npm pack/package-shape checks
npm run verify # Full pull-request gate
npm run test:mutation # Slow mutation tests for manual/nightly runs
```
Tests must not call live PinMe/IPFS/CAR services. Use `nock`, local loopback servers, fixtures, and temporary HOME directories for API and CLI scenarios.
For the full testing policy, layout, and mutation-testing guidance, see
[TESTING.md](TESTING.md).
## Limits and Operational Notes
- Default single-file upload limit: `100MB`
- Default directory upload limit: `500MB`
- These upload defaults come from the CLI and can be overridden with environment variables
- `update-db` enforces a total SQL payload limit of `10MB` per run
- `upload`, `import`, and project commands require authentication
- domain binding requires wallet balance
- `save`, `update-worker`, `update-db`, and `update-web` expect to run from a PinMe project root with `pinme.toml`
## Examples
This repo includes example projects and docs:
- [example/docs](./example/docs)
- [example/pinme-blog](./example/pinme-blog)
- [example/supabase](./example/supabase)
## Support
- Website: [https://pinme.eth.limo/](https://pinme.eth.limo/)
- GitHub: [https://github.com/glitternetwork/pinme](https://github.com/glitternetwork/pinme)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`glitternetwork/pinme`
- 原始仓库:https://github.com/glitternetwork/pinme
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+215
View File
@@ -0,0 +1,215 @@
# PinMe Testing Guide
This document describes the test system for the PinMe CLI. It is intended for
maintainers, contributors, and AI coding agents working on this repository.
## Goals
The test suite is designed to catch regressions across the full CLI lifecycle:
- TypeScript and lint checks.
- Unit tests for pure utility logic.
- Mocked integration tests for API wrappers and HTTP clients.
- Real CLI black-box tests against the bundled `dist/index.js`.
- npm package shape tests using `npm pack`.
- Coverage thresholds for the core in-process modules.
- Mutation testing for stricter confidence in critical logic.
Normal tests must not call real PinMe, IPFS, CAR, GitHub template, or other
external services. Use `nock`, local fixture servers, temporary HOME
directories, and test fixtures instead.
## Test Commands
Use these commands from the repository root.
```bash
npm run lint
npm run typecheck
npm run test
npm run test:coverage
npm run test:cli
npm run test:pack
npm run verify
npm run test:mutation
```
Command meanings:
| Command | Purpose |
| --- | --- |
| `npm run lint` | Runs ESLint over TypeScript, MJS tests, and Vitest config files. |
| `npm run typecheck` | Runs `tsc --noEmit` with `tsconfig.test.json`. |
| `npm run test` | Runs unit, integration, source-regression, and legacy MJS tests. |
| `npm run test:coverage` | Runs the same in-process tests with V8 coverage thresholds. |
| `npm run build` | Bundles the CLI to `dist/index.js` with esbuild. |
| `npm run test:cli` | Builds and runs black-box CLI tests against `node dist/index.js`. |
| `npm run test:pack` | Builds, packs, installs, and verifies the npm package shape. |
| `npm run verify` | Main PR gate: lint, typecheck, tests, coverage, build, CLI, and pack. |
| `npm run test:mutation` | Slow strict check using Stryker mutation testing. |
For pull requests, `npm run verify` is the required local confidence check.
Mutation testing is intentionally slower and is best run before risky releases,
large refactors, or from scheduled/manual CI jobs.
## Test Layout
```text
test/
unit/ Pure utility and service tests.
integration/ Mocked API/client integration tests.
cli/ Real bundled CLI black-box tests.
pack/ npm pack and installed-tarball tests.
helpers/ Shared test helpers.
setup/ Global test setup such as nock network guards.
tests/ Existing MJS regression tests.
```
Important files:
- `vitest.config.ts` controls the normal Vitest and coverage setup.
- `vitest.mutation.config.ts` narrows Stryker's test set to unit/integration
tests so mutation runs do not execute slow CLI/package black-box tests.
- `stryker.config.json` lists the core files mutation testing is allowed to
mutate.
- `.github/workflows/ci.yml` runs the open-source CI gates.
## Coverage Policy
Coverage focuses on in-process core modules where V8 can reliably attribute
executed code back to TypeScript source files.
Currently covered core modules include:
- `bin/utils/domainValidator.ts`
- `bin/utils/config.ts`
- `bin/utils/uploadLimits.ts`
- `bin/utils/apiClient.ts`
- `bin/utils/cliError.ts`
- `bin/utils/history.ts`
- `bin/utils/pinmeApi.ts`
- `bin/utils/webLogin.ts`
- `bin/services/uploadService.ts`
The configured minimums are:
| Metric | Minimum |
| --- | ---: |
| Statements | 85% |
| Branches | 80% |
| Functions | 85% |
| Lines | 85% |
Command files are primarily covered by `test:cli`, which executes the bundled
CLI as a subprocess. Subprocess coverage is not reliably attributed back to the
original TypeScript command files, so those checks live in CLI tests rather than
the V8 coverage gate.
## Mutation Policy
Mutation testing is configured for critical utility/API/service logic rather
than the entire repository. This keeps the signal high and avoids very slow or
flaky mutants in CLI subprocess tests.
Run:
```bash
npm run test:mutation
```
Current target:
- Overall mutation score should stay above the configured Stryker break
threshold.
- The practical project target is `80+`.
- If the score drops, first inspect survivors in:
```text
reports/mutation/index.html
```
The report directory is generated output and should not be committed.
## Network and Filesystem Rules
Tests must be hermetic by default.
- `test/setup/nock.ts` disables accidental external network access for normal
unit and integration tests.
- API behavior should be mocked with `nock`.
- CLI success-path tests may use local HTTP servers bound to `127.0.0.1`.
- Tests that touch user auth must isolate `HOME` and `~/.pinme` with temporary
directories.
- Do not depend on the real user's PinMe credentials.
- Do not write persistent files outside temporary directories unless the test is
explicitly verifying package/build output inside the repository.
In restricted sandboxes, CLI success-path tests can fail with:
```text
listen EPERM: operation not permitted 127.0.0.1
```
That means the sandbox blocked local mock servers. Re-run the command in an
environment that permits local loopback listeners.
## What To Test When Changing Code
Use the narrowest command while developing, then run the full gate before
opening a PR.
| Change area | Recommended tests |
| --- | --- |
| Pure utility logic | `npm run test -- test/unit/<file>.test.ts` |
| API wrappers or Axios client behavior | `npm run test -- test/integration` |
| Auth file handling | `npm run test -- test/unit/webLogin.test.ts` |
| Upload URL/result formatting | `npm run test -- test/unit/uploadService.test.ts` |
| CLI command behavior | `npm run build && vitest run test/cli` |
| Build or package metadata | `npm run test:pack` |
| Release confidence | `npm run verify && npm run test:mutation` |
Before finishing a non-trivial change, run:
```bash
npm run verify
```
Before finishing a risky core-logic change, also run:
```bash
npm run test:mutation
```
## Adding New Tests
Choose the test layer based on what can catch the bug most directly:
- Put pure function and local formatting tests in `test/unit/`.
- Put mocked API behavior in `test/integration/`.
- Put user-visible command behavior in `test/cli/`.
- Put publish/install behavior in `test/pack/`.
- Put old MJS regression tests in `tests/` only when matching existing
regression-test style.
Guidelines:
- Prefer testing public or intentionally exported helper behavior.
- Keep external services mocked.
- Use realistic fixtures for CLI tests.
- Assert both success output and failure messages when user behavior matters.
- Avoid brittle snapshots for colorful CLI output; normalize ANSI output when
necessary.
- If an internal function is hard to test, prefer a small pure helper export
over changing runtime behavior.
## CI Expectations
The GitHub Actions workflow keeps normal contribution feedback fast:
- Pull requests run `npm run verify` across supported Node versions.
- Mutation testing is scheduled/manual rather than required for every PR.
- Audit checks are non-blocking so dependency advisories can be triaged without
preventing unrelated contributions.
Generated directories such as `coverage/`, `reports/`, and `.stryker-tmp/`
should remain ignored and uncommitted.
+274
View File
@@ -0,0 +1,274 @@
import path from 'path';
import chalk from 'chalk';
import inquirer from 'inquirer';
import {
checkDomainAvailable,
bindPinmeDomain,
bindDnsDomainV4,
getRootDomain,
getWalletBalance,
} from './utils/pinmeApi';
import { printCliError, printRechargeUrl } from './utils/cliError';
import { getWalletRechargeUrl } from './utils/config';
import { getAuthConfig } from './utils/webLogin';
import {
isDnsDomain,
normalizeDomain,
validateDnsDomain,
} from './utils/domainValidator';
import { uploadPath } from './services/uploadService';
import tracker, {
getPathKind,
getTrackErrorReason,
} from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
interface Args {
domain?: string;
targetPath?: string;
dns?: boolean;
}
function parseArgs(): Args {
// Usage: pinme bind <path> --domain <name> [--dns]
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];
}
const dnsIdx = args.findIndex((a) => a === '--dns' || a === '-D');
if (dnsIdx >= 0) {
res.dns = true;
}
return res;
}
async function checkWalletBalanceStatus(authConfig: {
address: string;
token: string;
}): Promise<boolean> {
console.log(chalk.blue('Checking wallet balance...'));
try {
const balanceResult = await getWalletBalance(
authConfig.address,
authConfig.token,
);
const balance = Number(balanceResult.data?.wallet_balance_usd ?? 0);
if (!Number.isFinite(balance) || balance <= 0) {
return false;
}
console.log(
chalk.green(`Wallet balance available: $${balance.toFixed(2)}`),
);
return true;
} catch (e: any) {
if (e.message === 'Token expired' || e?.name === 'CliError') {
throw e;
}
console.log(chalk.yellow('Failed to check wallet balance, continuing...'));
return true;
}
}
export default async function bindCmd(): Promise<void> {
try {
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: ',
},
]);
targetPath = ans.path;
}
if (!domain) {
const ans = await inquirer.prompt([
{
type: 'input',
name: 'domain',
message: 'Enter the domain to bind (e.g., my-site or example.com): ',
},
]);
domain = ans.domain?.trim();
}
if (!targetPath || !domain) {
console.log(
chalk.red('Missing parameters. Path and domain are required.'),
);
return;
}
// Auto-detect domain type if not explicitly specified
const isDns = dns || isDnsDomain(domain);
const displayDomain = normalizeDomain(domain);
const pathKind = getPathKind(path.resolve(targetPath));
const domainType = isDns ? 'dns' : 'pinme_subdomain';
// Validate DNS domain format
if (isDns) {
const validation = validateDnsDomain(domain);
if (!validation.valid) {
console.log(chalk.red(validation.message!));
return;
}
}
// Domain binding now uses wallet balance instead of VIP.
try {
const hasWalletBalance = await checkWalletBalanceStatus(authConfig);
if (!hasWalletBalance) {
console.log(
chalk.red(
'Insufficient wallet balance. Please recharge your wallet first.',
),
);
printRechargeUrl(getWalletRechargeUrl());
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;
}
// Upload
const absolutePath = path.resolve(targetPath);
console.log(chalk.blue(`Uploading: ${absolutePath}`));
const up = await uploadPath(absolutePath, {
action: 'bind',
uid: authConfig.address,
});
if (!up?.contentHash) {
void tracker.trackEvent(TRACK_EVENTS.uploadFailed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadFailed),
path_kind: pathKind,
has_domain: true,
reason: 'no_content_hash',
});
console.log(chalk.red('Upload failed, binding aborted.'));
return;
}
void tracker.trackEvent(TRACK_EVENTS.uploadSuccess, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadSuccess),
path_kind: pathKind,
has_domain: true,
});
console.log(chalk.green(`Upload success, CID: ${up.contentHash}`));
// Bind domain
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) {
void tracker.trackEvent(TRACK_EVENTS.domainBindFailed, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindFailed),
domain_type: domainType,
domain_name: displayDomain,
bind_source: 'bind',
reason: dnsResult.msg || 'dns_bind_failed',
});
console.log(chalk.red(`DNS binding failed: ${dnsResult.msg}`));
return;
}
void tracker.trackEvent(TRACK_EVENTS.domainBindSuccess, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindSuccess),
domain_type: domainType,
domain_name: displayDomain,
bind_source: 'bind',
});
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) {
void tracker.trackEvent(TRACK_EVENTS.domainBindFailed, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindFailed),
domain_type: domainType,
domain_name: displayDomain,
bind_source: 'bind',
reason: 'pinme_bind_failed',
});
console.log(chalk.red('Binding failed. Please try again later.'));
return;
}
void tracker.trackEvent(TRACK_EVENTS.domainBindSuccess, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindSuccess),
domain_type: domainType,
domain_name: displayDomain,
bind_source: 'bind',
});
console.log(chalk.green(`Bind success: ${displayDomain}`));
const rootDomain = await getRootDomain();
console.log(
chalk.white(`Visit: https://${displayDomain}.${rootDomain}`),
);
}
} catch (e: any) {
if (e.message === 'Token expired') {
return; // Token expired hint already shown in API
}
throw e;
}
} catch (e: any) {
void tracker.trackEvent(TRACK_EVENTS.domainBindFailed, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindFailed),
bind_source: 'bind',
reason: getTrackErrorReason(e),
});
printCliError(e, 'Bind failed.');
}
}
+566
View File
@@ -0,0 +1,566 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import inquirer from 'inquirer';
import axios from 'axios';
import AdmZip from 'adm-zip';
import { getAuthHeaders } from './utils/webLogin';
import { startBackgroundInstall } from './utils/installProjectDependencies';
import {
createApiError,
createCommandError,
createConfigError,
printCliError,
} from './utils/cliError';
import { APP_CONFIG, getPinmeApiUrl } from './utils/config';
import { uploadPath } from './services/uploadService';
import { printHighlightedUrl } from './utils/urlDisplay';
import { patchPrebuiltFrontendDist } from './utils/prebuiltDistConfig';
import { getValidatedWorkerMetadataContent } from './utils/workerMetadata';
import { downloadFileWithRetries, getDownloadErrorMessage } from './utils/downloadFile';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
// Template directory - relative to bin folder (works both in dev and npm)
const PROJECT_DIR = process.cwd();
const TEMPLATE_BRANCH = process.env.PINME_TEMPLATE_BRANCH || 'main';
// 模板仓库地址 (使用 HTTPS 下载 zip)
const TEMPLATE_REPO = 'glitternetwork/pinme-worker-template';
const TEMPLATE_REPO_NAME = TEMPLATE_REPO.split('/').pop() || 'pinme-worker-template';
function getTemplateZipUrl(branch: string): string {
return `https://github.com/${TEMPLATE_REPO}/archive/refs/heads/${encodeURIComponent(branch)}.zip`;
}
interface CreateOptions {
name?: string;
force?: boolean;
}
interface CreateWorkerResponse {
api_domain: string;
metadata: string;
project_name: string;
uuid: string;
api_key?: string;
public_client_config?: Record<string, any>;
}
function buildPublicClientConfigExport(publicClientConfig: Record<string, any>): string {
return `export const public_client_config = ${JSON.stringify(publicClientConfig, null, 2)};\n`;
}
function injectPublicClientConfigIntoFile(fileContent: string, publicClientConfig: Record<string, any>): string {
const configExport = buildPublicClientConfigExport(publicClientConfig).trimEnd();
const configPattern = /export\s+const\s+public_client_config\s*=\s*\{[\s\S]*?\};?/m;
if (configPattern.test(fileContent)) {
return fileContent.replace(configPattern, configExport);
}
const trimmed = fileContent.trimEnd();
if (!trimmed) {
return `${configExport}\n`;
}
return `${trimmed}\n\n${configExport}\n`;
}
function resolveExtractedTemplateDir(extractDir: string): string {
const entries = fs.readdirSync(extractDir, { withFileTypes: true });
const templateDir = entries.find((entry) => (
entry.isDirectory() && entry.name.startsWith(`${TEMPLATE_REPO_NAME}-`)
));
if (!templateDir) {
throw createConfigError('Downloaded template archive structure is invalid.', [
`Expected a directory starting with \`${TEMPLATE_REPO_NAME}-\` inside the extracted archive.`,
`Template branch: ${TEMPLATE_BRANCH}`,
]);
}
return path.join(extractDir, templateDir.name);
}
function updateFrontendUrlInConfig(configPath: string, frontendUrl: string): void {
let config = fs.readFileSync(configPath, 'utf-8');
if (config.includes('frontend_url')) {
config = config.replace(
/frontend_url\s*=\s*"[^"]*"/,
`frontend_url = "${frontendUrl}"`,
);
} else {
config = config.replace(
/(project_name\s*=\s*"[^"]*"\n)/,
`$1frontend_url = "${frontendUrl}"\n`,
);
}
fs.writeFileSync(configPath, config);
}
function getProjectManagementUrl(projectName: string): string {
return `${APP_CONFIG.projectPeviewUrl}${projectName}`;
}
/**
* Create a new project from template
* 1. Check login
* 2. Call API to create worker/D1 database
* 3. Copy template files
* 4. Update configuration files
*/
export default async function createCmd(options: CreateOptions): Promise<void> {
try {
// Check if user is logged in
const headers = getAuthHeaders();
if (!headers['authentication-tokens'] || !headers['token-address']) {
throw createConfigError('No valid local login session was found.', [
'Run `pinme login` and retry.',
]);
}
console.log(chalk.blue('Creating new project from template...\n'));
// Get project name from options or prompt
let projectName = options.name;
if (!projectName) {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'projectName',
message: 'Enter project name:',
validate: (input: string) => {
if (!input.trim()) return 'Project name is required';
if (!/^[a-zA-Z0-9-_]+$/.test(input)) {
return 'Project name can only contain letters, numbers, hyphens and underscores';
}
return true;
},
},
]);
projectName = answers.projectName;
}
const targetDir = path.join(PROJECT_DIR, projectName);
// Check if directory exists
if (fs.existsSync(targetDir) && !options.force) {
console.log(chalk.yellow(`\nDirectory "${projectName}" already exists.`));
const answers = await inquirer.prompt([
{
type: 'confirm',
name: 'overwrite',
message: 'Do you want to overwrite it?',
default: false,
},
]);
if (!answers.overwrite) {
console.log(chalk.gray('Cancelled.'));
process.exit(0);
}
fs.removeSync(targetDir);
}
// 1. Call API to create worker/D1
console.log(chalk.blue('\n1. Creating worker and database...'));
const apiUrl = getPinmeApiUrl('/create_worker');
console.log(chalk.gray(`API URL: ${apiUrl}`));
// Convert project name to lowercase (API requires lowercase)
const normalizedProjectName = projectName.toLowerCase();
console.log(chalk.gray(`Project name: ${normalizedProjectName}`));
let workerData: CreateWorkerResponse;
try {
const response = await axios.post(apiUrl, {
project_name: normalizedProjectName,
}, {
headers: {
...headers,
'Content-Type': 'application/json',
},
});
const data = response.data;
if (data.code !== 200) {
throw createApiError('project creation', { response: { status: response.status, data } }, [
`Project name: ${normalizedProjectName}`,
`Endpoint: ${apiUrl}`,
]);
}
workerData = data.data;
console.log(chalk.gray(` API Response: ${JSON.stringify(workerData)}`));
console.log(chalk.green(` API Domain: ${workerData.api_domain}`));
console.log(chalk.green(` Project Name: ${workerData.project_name}`));
} catch (error: any) {
throw createApiError('project creation', error, [
`Project name: ${normalizedProjectName}`,
`Endpoint: ${apiUrl}`,
]);
}
// 2. Download template from repository (using HTTPS, no git required)
console.log(chalk.blue('\n2. Downloading template from repository...'));
const zipPath = path.join(PROJECT_DIR, 'template.zip');
const extractDir = path.join(PROJECT_DIR, `.pinme-template-${Date.now()}`);
const templateZipUrl =
process.env.PINME_TEMPLATE_ZIP_URL || getTemplateZipUrl(TEMPLATE_BRANCH);
console.log(chalk.gray(` Template branch: ${TEMPLATE_BRANCH}`));
try {
const downloadResult = await downloadFileWithRetries(templateZipUrl, zipPath, {
attempts: 3,
retryDelayMs: 2000,
minBytes: 100,
onAttempt: (attempt, attempts) => {
console.log(chalk.gray(` Download attempt ${attempt}/${attempts}...`));
},
onAttemptFailure: (attempt, error) => {
console.log(chalk.yellow(` Attempt ${attempt} failed: ${getDownloadErrorMessage(error)}`));
},
});
console.log(chalk.green(` Template archive downloaded (${downloadResult.bytes} bytes)`));
} catch (error: any) {
throw createCommandError('template download', `download "${templateZipUrl}" to "${zipPath}"`, error, [
'Check your network connection and retry `pinme create`.',
`Verify that the template branch exists: ${TEMPLATE_BRANCH}`,
]);
}
try {
fs.ensureDirSync(extractDir);
// Extract with Node so Windows users do not need a system `unzip` command.
const templateZip = new AdmZip(zipPath);
templateZip.extractAllTo(extractDir, true);
// Move files from extracted subdirectory to target directory
const subDir = resolveExtractedTemplateDir(extractDir);
fs.copySync(subDir, targetDir);
// Clean up zip file
fs.removeSync(zipPath);
fs.removeSync(extractDir);
// Remove any existing node_modules to avoid platform-specific dependency leftovers.
// This fixes issues with rollup platform-specific dependencies
const nodeModulesPath = path.join(targetDir, 'node_modules');
if (fs.existsSync(nodeModulesPath)) {
console.log(chalk.gray(' Removing existing node_modules...'));
fs.removeSync(nodeModulesPath);
}
// Also clean frontend and backend subdirectories
const frontendNodeModules = path.join(targetDir, 'frontend', 'node_modules');
const backendNodeModules = path.join(targetDir, 'backend', 'node_modules');
if (fs.existsSync(frontendNodeModules)) fs.removeSync(frontendNodeModules);
if (fs.existsSync(backendNodeModules)) fs.removeSync(backendNodeModules);
console.log(chalk.green(` Template downloaded to: ${targetDir}`));
} catch (error: any) {
throw createCommandError('template extraction', `extract "${zipPath}" to "${extractDir}"`, error, [
'Check whether the downloaded template archive is valid and has the expected GitHub archive structure.',
]);
}
// 3. Update pinme.toml with project_name
console.log(chalk.blue('\n3. Updating configuration...'));
const configPath = path.join(targetDir, 'pinme.toml');
const config = fs.readFileSync(configPath, 'utf-8');
// Update project_name
let updatedConfig = config.replace(
/project_name = ".*"/,
`project_name = "${workerData.project_name}"`
);
fs.writeFileSync(configPath, updatedConfig);
console.log(chalk.green(` Updated pinme.toml`));
console.log(chalk.gray(` metadata: ${workerData.metadata}`));
const workerMetadataContent = getValidatedWorkerMetadataContent(
workerData.metadata,
workerData.project_name,
);
// 4. Save metadata to backend directory
const backendDir = path.join(targetDir, 'backend');
if (fs.existsSync(backendDir)) {
fs.writeFileSync(
path.join(backendDir, 'metadata.json'),
workerMetadataContent
);
console.log(chalk.green(` Saved metadata.json`));
}
// 4.1 Update API_KEY in backend/wrangler.toml
const wranglerPath = path.join(backendDir, 'wrangler.toml');
if (fs.existsSync(wranglerPath) && workerData.api_key) {
let wranglerContent = fs.readFileSync(wranglerPath, 'utf-8');
wranglerContent = wranglerContent.replace(
/^name = ".*"$/m,
`name = "${workerData.project_name}"`
);
fs.writeFileSync(wranglerPath, wranglerContent);
console.log(chalk.green(` Updated backend/wrangler.toml API_KEY`));
}
const frontendConfigPath = path.join(targetDir, 'frontend', 'src', 'utils', 'config.ts');
if (workerData.public_client_config) {
const frontendConfigContent = fs.existsSync(frontendConfigPath)
? fs.readFileSync(frontendConfigPath, 'utf-8')
: '';
fs.ensureDirSync(path.dirname(frontendConfigPath));
fs.writeFileSync(
frontendConfigPath,
injectPublicClientConfigIntoFile(frontendConfigContent, workerData.public_client_config)
);
console.log(chalk.green(` Updated frontend/src/utils/config.ts public_client_config`));
}
// 5. Create .env file from .env.example (in frontend directory)
const envExamplePath = path.join(targetDir, 'frontend', '.env.example');
const envPath = path.join(targetDir, 'frontend', '.env');
if (fs.existsSync(envExamplePath)) {
let envContent = fs.readFileSync(envExamplePath, 'utf-8');
// Replace your-project with actual project name
envContent = envContent.replace(/your-project/g, workerData.project_name);
// Update API_URL - match entire line
envContent = envContent.replace(
/^VITE_API_URL=.*$/m,
`VITE_API_URL=${workerData.api_domain}`
);
fs.writeFileSync(envPath, envContent);
console.log(chalk.green(` Created frontend/.env file`));
console.log(chalk.gray(` VITE_API_URL: ${workerData.api_domain}`));
}
// Update pinme.toml with api_url (append after project_name)
let pinmeConfig = fs.readFileSync(configPath, 'utf-8');
if (pinmeConfig.includes('api_url')) {
pinmeConfig = pinmeConfig.replace(
/api_url\s*=\s*"[^"]*"/,
`api_url = "${workerData.api_domain}"`
);
} else {
// Append api_url line after project_name
const lines = pinmeConfig.split('\n');
const newLines: string[] = [];
for (const line of lines) {
newLines.push(line);
if (line.trim().startsWith('project_name')) {
newLines.push(`api_url = "${workerData.api_domain}"`);
}
}
pinmeConfig = newLines.join('\n');
}
fs.writeFileSync(configPath, pinmeConfig);
console.log(chalk.green(` Updated pinme.toml with api_url`));
// 6. Install dependencies in the background.
// The template ships prebuilt `dist-worker/` and `frontend/dist/`, so the
// initial create + deploy below does NOT need node_modules. We kick off the
// install asynchronously so dependencies are ready for later `pinme save`
// and local development, without blocking (or failing) the first create.
console.log(chalk.blue('\n4. Installing dependencies in the background...'));
try {
const { logPath } = startBackgroundInstall(targetDir);
console.log(chalk.gray(' Dependencies are installing in the background; create will continue.'));
console.log(chalk.gray(` Install progress is logged to: ${logPath}`));
console.log(chalk.gray(' `pinme save` will automatically wait for this install to finish.'));
} catch (error: any) {
// A failed background install must not block create; the prebuilt dist is enough.
console.log(chalk.yellow(' Warning: could not start background dependency install.'));
console.log(chalk.yellow(' Run `npm install` inside the project before `pinme save`.'));
}
// 7. Use the prebuilt backend worker shipped with the template
console.log(chalk.blue('\n5. Preparing backend worker...'));
// Get prebuilt worker files and SQL files
const distWorkerDir = path.join(targetDir, 'dist-worker');
const workerJsPath = path.join(distWorkerDir, 'worker.js');
if (!fs.existsSync(distWorkerDir) || !fs.existsSync(workerJsPath)) {
throw createConfigError('Prebuilt worker output not found: `dist-worker/worker.js`.', [
'The template should ship a prebuilt `dist-worker/`.',
'Once dependencies finish installing, run `npm run build:worker` in the project, then `pinme save`.',
]);
}
// Get module files
const modulePaths: string[] = [];
const files = fs.readdirSync(distWorkerDir);
for (const file of files) {
if (file.endsWith('.js') && file !== 'worker.js') {
modulePaths.push(path.join(distWorkerDir, file));
}
}
// Get SQL files
const sqlDir = path.join(targetDir, 'db');
const sqlFiles: string[] = [];
if (fs.existsSync(sqlDir)) {
const sqlFileNames = fs.readdirSync(sqlDir)
.filter(f => f.endsWith('.sql'))
.sort();
for (const filename of sqlFileNames) {
sqlFiles.push(path.join(sqlDir, filename));
console.log(chalk.gray(` Including SQL: ${filename}`));
}
}
// 9. Save worker to platform
console.log(chalk.blue('\n6. Deploying backend worker...'));
const saveApiUrl = `${getPinmeApiUrl('/save_worker')}?project_name=${encodeURIComponent(workerData.project_name)}`;
console.log(chalk.gray(` API URL: ${saveApiUrl}`));
try {
const FormData = (await import('formdata-node')).FormData;
const Blob = (await import('formdata-node')).Blob;
const formData = new FormData() as any;
// Add metadata
formData.append('metadata', new Blob([workerMetadataContent], {
type: 'application/json',
}), 'metadata.json');
// Add worker.js
const workerCode = fs.readFileSync(workerJsPath, 'utf-8');
formData.append('worker.js', new Blob([workerCode], {
type: 'application/javascript+module',
}), 'worker.js');
// Add other modules
for (const modulePath of modulePaths) {
const filename = path.basename(modulePath);
const content = fs.readFileSync(modulePath, 'utf-8');
formData.append(filename, new Blob([content], {
type: 'application/javascript+module',
}), filename);
}
// Add SQL files
for (const sqlFile of sqlFiles) {
const filename = path.basename(sqlFile);
const content = fs.readFileSync(sqlFile, 'utf-8');
formData.append('sql_file', new Blob([content], {
type: 'application/sql',
}), filename);
}
const response = await axios.put(saveApiUrl, formData, {
headers: { ...headers },
timeout: 120000,
});
if (response.data) {
console.log(chalk.green(' Worker deployed'));
if (response.data?.data?.sql_results) {
for (const result of response.data.data.sql_results) {
console.log(chalk.gray(` SQL ${result.filename}: ${result.status}`));
}
}
} else {
throw createApiError('worker save', { response: { data: response.data } }, [
`Project: ${workerData.project_name}`,
`Endpoint: ${saveApiUrl}`,
], [
'Verify the project exists and your account has permission to update it.',
]);
}
} catch (error: any) {
throw createApiError('worker save', error, [
`Project: ${workerData.project_name}`,
`Endpoint: ${saveApiUrl}`,
], [
'Check whether backend metadata, SQL files, or worker bundle contains invalid content.',
]);
}
// 10. Deploy the prebuilt frontend shipped with the template
console.log(chalk.blue('\n7. Preparing frontend...'));
const frontendDir = path.join(targetDir, 'frontend');
const frontendDistDir = path.join(frontendDir, 'dist');
if (fs.existsSync(frontendDir)) {
if (!fs.existsSync(frontendDistDir)) {
throw createConfigError('Prebuilt frontend output not found: `frontend/dist/`.', [
'The template should ship a prebuilt `frontend/dist/`.',
'Once dependencies finish installing, run `npm run build:frontend` in the project, then `pinme save`.',
]);
}
const patchResult = patchPrebuiltFrontendDist(frontendDistDir, workerData);
console.log(chalk.green(' Patched prebuilt frontend dist config'));
console.log(chalk.gray(
` Replacements: API URL ${patchResult.apiUrlReplacements}, auth ${patchResult.authReplacements}`,
));
// Upload to IPFS and capture the URL
console.log(chalk.blue(' Uploading to IPFS...'));
try {
const uploadResult = await uploadPath(frontendDistDir, {
action: 'project_create',
projectName: workerData.project_name,
uid: headers['token-address'],
});
printHighlightedUrl('Frontend URL', uploadResult.publicUrl, 'primary');
updateFrontendUrlInConfig(
path.join(targetDir, 'pinme.toml'),
uploadResult.publicUrl,
);
console.log(chalk.green(' Updated pinme.toml with frontend URL'));
} catch (error: any) {
console.log(chalk.yellow(' Warning: IPFS upload failed, you can upload manually later'));
}
}
console.log(chalk.green('\nProject created successfully.'));
console.log(chalk.gray(`\nProject Details:`));
console.log(chalk.gray(` API Domain: ${workerData.api_domain}`));
console.log(chalk.gray(` Project Name: ${workerData.project_name}`));
printHighlightedUrl(
'Project Management URL',
getProjectManagementUrl(workerData.project_name),
'management',
);
console.log(chalk.gray(`\nNext steps:`));
console.log(chalk.gray(` cd ${projectName}`));
console.log(chalk.gray(` pinme save`));
void tracker.trackEvent(
TRACK_EVENTS.projectCreateSuccess,
TRACK_PAGES.project,
{
a: resolveTrackAction(TRACK_EVENTS.projectCreateSuccess),
project_name: workerData.project_name,
template_branch: TEMPLATE_BRANCH,
force: Boolean(options.force),
},
);
process.exit(0);
} catch (error: any) {
void tracker.trackEvent(
TRACK_EVENTS.projectCreateFailed,
TRACK_PAGES.project,
{
a: resolveTrackAction(TRACK_EVENTS.projectCreateFailed),
project_name: options.name,
template_branch: TEMPLATE_BRANCH,
force: Boolean(options.force),
reason: getTrackErrorReason(error),
},
);
printCliError(error, 'Project creation failed.');
process.exit(1);
}
}
+146
View File
@@ -0,0 +1,146 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import { getAuthHeaders } from './utils/webLogin';
import { getPinmeApiUrl } from './utils/config';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
interface DeleteOptions {
name?: string;
force?: boolean;
}
// 从 pinme.toml 获取项目名
function getProjectName(): string | null {
const configPath = path.join(process.cwd(), 'pinme.toml');
if (!fs.existsSync(configPath)) {
return null;
}
const config = fs.readFileSync(configPath, 'utf-8');
const match = config.match(/project_name\s*=\s*"([^"]+)"/);
return match?.[1] || null;
}
/**
* Delete a project: removes Worker domain binding, Worker script, D1 database
*/
export default async function deleteCmd(options: DeleteOptions): Promise<void> {
try {
// Check if user is logged in
const headers = getAuthHeaders();
if (!headers['authentication-tokens'] || !headers['token-address']) {
console.log(chalk.yellow('\n⚠️ You are not logged in.'));
console.log(chalk.gray('Please run: pinme login'));
process.exit(1);
}
console.log(chalk.blue('Deleting project...\n'));
// Get project name from pinme.toml or options
let projectName = options.name || getProjectName();
if (!projectName) {
console.log(chalk.red('\n❌ Error: Cannot find project name.'));
console.log(chalk.yellow(' Please make sure you are in the project directory.'));
console.log(chalk.gray(' The project directory should contain a pinme.toml file.'));
console.log(chalk.gray('\n Or specify the project name:'));
console.log(chalk.gray(' cd /path/to/your-project'));
console.log(chalk.gray(' pinme delete'));
process.exit(1);
}
console.log(chalk.gray(`Project: ${projectName}`));
console.log(chalk.gray(`Directory: ${process.cwd()}`));
// Confirm deletion
if (!options.force) {
const answers = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to delete project "${projectName}"? This will remove Worker, domain binding, and D1 database.`,
default: false,
},
]);
if (!answers.confirm) {
console.log(chalk.gray('Cancelled.'));
process.exit(0);
}
}
// Call API to delete project
console.log(chalk.blue('Deleting project on platform...'));
const apiUrl = getPinmeApiUrl('/delete_project');
console.log(chalk.gray(`API URL: ${apiUrl}`));
console.log(chalk.gray(`Project name: ${projectName}`));
const response = await axios.post(apiUrl, {
project_name: projectName,
}, {
headers: {
...headers,
'Content-Type': 'application/json',
},
}).catch((error) => {
if (error.response) {
console.log(chalk.red(` Response status: ${error.response?.status}`));
console.log(chalk.red(` Response data: ${JSON.stringify(error.response?.data)}`));
} else {
console.log(chalk.red('No Response'))
}
throw error;
});
const data = response.data;
if (data.code === 200) {
void tracker.trackEvent(
TRACK_EVENTS.projectDeleteSuccess,
TRACK_PAGES.project,
{
a: resolveTrackAction(TRACK_EVENTS.projectDeleteSuccess),
project_name: data.data.project_name,
domain_deleted: Boolean(data.data.domain_deleted),
worker_deleted: Boolean(data.data.worker_deleted),
database_deleted: Boolean(data.data.database_deleted),
},
);
console.log(chalk.green('\n✅ Project deleted successfully!'));
console.log(chalk.gray(`\nProject: ${data.data.project_name}`));
console.log(chalk.gray(` Domain deleted: ${data.data.domain_deleted ? '✅' : '❌'}`));
console.log(chalk.gray(` Worker deleted: ${data.data.worker_deleted ? '✅' : '❌'}`));
console.log(chalk.gray(` Database deleted: ${data.data.database_deleted ? '✅' : '❌'}`));
console.log(chalk.gray('\nLocal files are kept unchanged.'));
} else {
const errorMsg = data?.msg || 'Failed to delete project';
throw new Error(errorMsg);
}
process.exit(0);
} catch (error: any) {
void tracker.trackEvent(
TRACK_EVENTS.projectDeleteFailed,
TRACK_PAGES.project,
{
a: resolveTrackAction(TRACK_EVENTS.projectDeleteFailed),
project_name: options.name || getProjectName() || undefined,
force: Boolean(options.force),
reason: getTrackErrorReason(error),
},
);
console.log(chalk.red(error));
const errorMsg = error.response?.data?.msg
|| error.message
|| 'Failed to delete project';
console.error(chalk.red(`\n❌ Error: ${errorMsg}`));
process.exit(1);
}
}
+300
View File
@@ -0,0 +1,300 @@
import path from 'path';
import os from 'os';
import chalk from 'chalk';
import inquirer from 'inquirer';
import figlet from 'figlet';
import fs from 'fs';
import axios from 'axios';
import ora from 'ora';
import { printCliError } from './utils/cliError';
import { requestCarExport, checkCarExportStatus } from './utils/pinmeApi';
import { getUid } from './utils/getDeviceId';
import { checkNodeVersion } from './utils/checkNodeVersion';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
checkNodeVersion();
// Polling configuration
const POLL_INTERVAL = 5000; // 5 seconds
const MAX_POLL_TIME = 30 * 60 * 1000; // 30 minutes
// Poll export status until completion
async function pollExportStatus(
taskId: string,
cid: string,
spinner: ora.Ora,
startTime: number,
): Promise<string | null> {
while (Date.now() - startTime < MAX_POLL_TIME) {
try {
const status = await checkCarExportStatus(taskId);
if (status.status === 'completed' && status.download_url) {
spinner.succeed(`Export completed for CID: ${cid}`);
return status.download_url;
} else if (status.status === 'failed') {
spinner.fail(`Export failed for CID: ${cid}`);
return null;
} else if (status.status === 'processing') {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
spinner.text = `Exporting CAR file... (${minutes}m ${seconds}s)`;
}
} catch (error: any) {
console.log(chalk.yellow(`Polling error: ${error.message}`));
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
const maxPollTimeMinutes = Math.floor(MAX_POLL_TIME / (60 * 1000));
spinner.fail(`Export timeout after ${maxPollTimeMinutes} minutes`);
return null;
}
// Download CAR file from URL
async function downloadCarFile(
downloadUrl: string,
outputPath: string,
): Promise<boolean> {
try {
const spinner = ora(`Downloading CAR file to ${outputPath}...`).start();
const response = await axios({
method: 'GET',
url: downloadUrl,
responseType: 'stream',
timeout: 1800000, // 30 minutes timeout
});
const writer = fs.createWriteStream(outputPath);
let downloadedBytes = 0;
const contentLength = response.headers['content-length'];
const totalBytes = parseInt(
typeof contentLength === 'string' ? contentLength : '0',
10,
);
response.data.on('data', (chunk: Buffer) => {
downloadedBytes += chunk.length;
if (totalBytes > 0) {
const progress = (downloadedBytes / totalBytes) * 100;
spinner.text = `Downloading CAR file... ${progress.toFixed(1)}%`;
}
});
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', () => {
spinner.succeed(`CAR file downloaded successfully: ${outputPath}`);
resolve(true);
});
writer.on('error', (error) => {
spinner.fail(`Download failed: ${error.message}`);
reject(error);
});
});
} catch (error: any) {
console.error(chalk.red(`Download error: ${error.message}`));
return false;
}
}
// Validate CID format (basic check)
function isValidCID(cid: string): boolean {
// Basic CID validation - should start with 'Qm' (CIDv0) or 'bafy' (CIDv1)
return /^(Qm|bafy|bafk|bafz)/.test(cid);
}
// Get CID from command line arguments
function getCidFromArgs(): string | null {
const args = process.argv.slice(2);
const cidIdx = args.findIndex((a) => a === 'export') + 1;
if (cidIdx > 0 && args[cidIdx] && !args[cidIdx].startsWith('-')) {
return args[cidIdx].trim();
}
return null;
}
// Get current working directory
function getCurrentDirectory(): string {
return process.cwd();
}
// Get output path from command line arguments
function getOutputPathFromArgs(): string | null {
const args = process.argv.slice(2);
const outputIdx = args.findIndex((a) => a === '--output' || a === '-o');
if (outputIdx >= 0 && args[outputIdx + 1] && !args[outputIdx + 1].startsWith('-')) {
return String(args[outputIdx + 1]).trim();
}
return null;
}
export default async (): Promise<void> => {
try {
console.log(
figlet.textSync('PINME EXPORT', {
font: 'Standard',
horizontalLayout: 'default',
verticalLayout: 'default',
width: 180,
whitespaceBreak: true,
}),
);
// Get CID from arguments or prompt
let cid = getCidFromArgs();
if (!cid) {
const answer = await inquirer.prompt([
{
type: 'input',
name: 'cid',
message: 'Enter CID to export: ',
validate: (input: string) => {
if (!input.trim()) {
return 'CID cannot be empty';
}
if (!isValidCID(input.trim())) {
return 'Invalid CID format. CID should start with Qm, bafy, bafk, or bafz';
}
return true;
},
},
]);
cid = answer.cid.trim();
}
if (!cid || !isValidCID(cid)) {
console.log(chalk.red('Invalid CID format. CID should start with Qm, bafy, bafk, or bafz'));
return;
}
// Get output directory (output parameter is always a directory, not a file)
let outputDir = getOutputPathFromArgs();
if (!outputDir) {
// Default to current directory
const currentDir = getCurrentDirectory();
const answer = await inquirer.prompt([
{
type: 'input',
name: 'output',
message: `Output directory (default: ${currentDir}): `,
default: currentDir,
},
]);
outputDir = answer.output.trim() || currentDir;
}
// Convert to absolute path
outputDir = path.resolve(outputDir);
// Create directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
} else if (!fs.statSync(outputDir).isDirectory()) {
// If path exists but is not a directory, show error
console.log(chalk.red(`Error: ${outputDir} exists but is not a directory.`));
return;
}
// Final output path is always {outputDir}/{cid}.car
const finalOutputPath = path.join(outputDir, `${cid}.car`);
// Check if file already exists
if (fs.existsSync(finalOutputPath)) {
const answer = await inquirer.prompt([
{
type: 'confirm',
name: 'overwrite',
message: `File ${finalOutputPath} already exists. Overwrite?`,
default: false,
},
]);
if (!answer.overwrite) {
console.log(chalk.blue('Export cancelled.'));
return;
}
}
// Get UID
const uid = getUid();
// Step 1: Request export
const spinner = ora(`Requesting CAR export for CID: ${cid}...`).start();
try {
const exportResponse = await requestCarExport(cid, uid);
spinner.succeed(`Export task created: ${exportResponse.task_id}`);
// Step 2: Poll for status
const pollSpinner = ora('Waiting for export to complete...').start();
const startTime = Date.now();
const downloadUrl = await pollExportStatus(
exportResponse.task_id,
cid,
pollSpinner,
startTime,
);
if (!downloadUrl) {
void tracker.trackEvent(TRACK_EVENTS.exportFailed, TRACK_PAGES.export, {
a: resolveTrackAction(TRACK_EVENTS.exportFailed),
cid,
reason: 'export_failed_or_timed_out',
});
console.log(chalk.red('Export failed or timed out.'));
return;
}
// Step 3: Download CAR file
const success = await downloadCarFile(downloadUrl, finalOutputPath);
if (success) {
void tracker.trackEvent(TRACK_EVENTS.exportSuccess, TRACK_PAGES.export, {
a: resolveTrackAction(TRACK_EVENTS.exportSuccess),
cid,
output_dir: outputDir,
});
const fileSize = fs.statSync(finalOutputPath).size;
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
console.log(
chalk.cyan(
figlet.textSync('Successful', { horizontalLayout: 'full' }),
),
);
console.log(chalk.green(`\n🎉 Export successful!`));
console.log(chalk.cyan(`File: ${finalOutputPath}`));
console.log(chalk.cyan(`Size: ${fileSizeMB} MB`));
console.log(chalk.cyan(`CID: ${cid}`));
} else {
void tracker.trackEvent(TRACK_EVENTS.exportFailed, TRACK_PAGES.export, {
a: resolveTrackAction(TRACK_EVENTS.exportFailed),
cid,
reason: 'download_failed',
});
console.log(chalk.red('Download failed.'));
}
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.exportFailed, TRACK_PAGES.export, {
a: resolveTrackAction(TRACK_EVENTS.exportFailed),
cid,
reason: getTrackErrorReason(error),
});
spinner.fail(`Error: ${error.message}`);
printCliError(error, 'Export failed.');
}
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.exportFailed, TRACK_PAGES.export, {
a: resolveTrackAction(TRACK_EVENTS.exportFailed),
reason: getTrackErrorReason(error),
});
printCliError(error, 'Export failed.');
}
};
+328
View File
@@ -0,0 +1,328 @@
import path from 'path';
import chalk from 'chalk';
import inquirer from 'inquirer';
import figlet from 'figlet';
import fs from 'fs';
import CryptoJS from 'crypto-js';
import {
checkDomainAvailable,
bindPinmeDomain,
getRootDomain,
} from './utils/pinmeApi';
import { printCliError } from './utils/cliError';
import { getAuthConfig } from './utils/webLogin';
import { APP_CONFIG } from './utils/config';
import { uploadPath } from './services/uploadService';
import { printHighlightedUrl } from './utils/urlDisplay';
import tracker, {
getPathKind,
getTrackErrorReason,
} from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
// get from environment variables
import { checkNodeVersion } from './utils/checkNodeVersion';
checkNodeVersion();
// 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');
}
// 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, '_')
.replace(/=+$/, '');
return urlSafe;
} catch (error: any) {
console.error(`Encryption error: ${error.message}`);
return contentHash;
}
}
// create a synchronous path check function
function checkPathSync(inputPath: string): string | null {
try {
// convert to absolute path
const absolutePath = path.resolve(inputPath);
// check if the path exists
if (fs.existsSync(absolutePath)) {
return absolutePath;
}
return null;
} catch (error: any) {
console.error(chalk.red(`error checking path: ${error.message}`));
return null;
}
}
interface ImportOptions {
[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;
}
// Upload/import now requires login. Use the authenticated address as uid.
function getUid(): string {
const auth = getAuthConfig();
if (auth?.address) {
return auth.address;
}
throw new Error('Please login first. Run: pinme login');
}
export default async (options?: ImportOptions): Promise<void> => {
try {
console.log(
figlet.textSync('PINME IMPORT', {
font: 'Standard',
horizontalLayout: 'default',
verticalLayout: 'default',
width: 180,
whitespaceBreak: true,
}),
);
const auth = getAuthConfig();
if (!auth) {
console.log(chalk.red('Please login first. Run: pinme login'));
return;
}
// if the parameter is passed, import directly, pinme import /path/to/dir
const argPath = process.argv[3];
const domainArg = getDomainFromArgs();
if (argPath && !argPath.startsWith('-')) {
// use the synchronous path check function
const absolutePath = checkPathSync(argPath);
if (!absolutePath) {
console.log(chalk.red(`path ${argPath} does not exist`));
return;
}
const pathKind = getPathKind(absolutePath);
// optional: pre-check domain availability before import
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(`importing ${absolutePath} to ipfs as CAR...`));
try {
const result = await uploadPath(absolutePath, {
action: 'import',
importAsCar: true,
uid: getUid(),
});
if (result) {
void tracker.trackEvent(TRACK_EVENTS.importSuccess, TRACK_PAGES.import, {
a: resolveTrackAction(TRACK_EVENTS.importSuccess),
path_kind: pathKind,
has_domain: Boolean(domainArg),
});
const uid = getUid();
const encryptedCID = encryptHash(
result.contentHash,
APP_CONFIG.secretKey,
uid,
);
console.log(
chalk.cyan(
figlet.textSync('Successful', { horizontalLayout: 'full' }),
),
);
printHighlightedUrl(
'URL',
`${APP_CONFIG.ipfsPreviewUrl}${encryptedCID}`,
'primary',
);
// optional: bind domain after import
if (domainArg) {
console.log(
chalk.blue(
`Binding domain: ${domainArg} with CID: ${result.contentHash}`,
),
);
const ok = await bindPinmeDomain(domainArg, result.contentHash);
if (ok) {
void tracker.trackEvent(TRACK_EVENTS.domainBindSuccess, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindSuccess),
domain_type: 'pinme_subdomain',
domain_name: domainArg,
bind_source: 'import',
});
console.log(chalk.green(`Bind success: ${domainArg}`));
const rootDomain = await getRootDomain();
console.log(
chalk.white(
`Visit (Pinme subdomain example): https://${domainArg}.${rootDomain}`,
),
);
} else {
void tracker.trackEvent(TRACK_EVENTS.domainBindFailed, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindFailed),
domain_type: 'pinme_subdomain',
domain_name: domainArg,
bind_source: 'import',
reason: 'pinme_bind_failed',
});
console.log(chalk.red('Binding failed. Please try again later.'));
}
}
console.log(chalk.green('\n🎉 import successful, program exit'));
}
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.importFailed, TRACK_PAGES.import, {
a: resolveTrackAction(TRACK_EVENTS.importFailed),
path_kind: pathKind,
has_domain: Boolean(domainArg),
reason: getTrackErrorReason(error),
});
printCliError(error, 'Import failed.');
}
process.exit(0);
}
const answer = await inquirer.prompt([
{
type: 'input',
name: 'path',
message: 'path to import: ',
},
]);
if (answer.path) {
// use the synchronous path check function
const absolutePath = checkPathSync(answer.path);
if (!absolutePath) {
console.log(chalk.red(`path ${answer.path} does not exist`));
return;
}
const pathKind = getPathKind(absolutePath);
// 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(`importing ${absolutePath} to ipfs as CAR...`));
try {
const result = await uploadPath(absolutePath, {
action: 'import',
importAsCar: true,
uid: getUid(),
});
if (result) {
void tracker.trackEvent(TRACK_EVENTS.importSuccess, TRACK_PAGES.import, {
a: resolveTrackAction(TRACK_EVENTS.importSuccess),
path_kind: pathKind,
has_domain: Boolean(domainArg),
});
const uid = getUid();
const encryptedCID = encryptHash(
result.contentHash,
APP_CONFIG.secretKey,
uid,
);
console.log(
chalk.cyan(
figlet.textSync('Successful', { horizontalLayout: 'full' }),
),
);
printHighlightedUrl(
'URL',
`${APP_CONFIG.ipfsPreviewUrl}${encryptedCID}`,
'primary',
);
if (domainArg) {
console.log(
chalk.blue(
`Binding domain: ${domainArg} with CID: ${result.contentHash}`,
),
);
const ok = await bindPinmeDomain(domainArg, result.contentHash);
if (ok) {
void tracker.trackEvent(TRACK_EVENTS.domainBindSuccess, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindSuccess),
domain_type: 'pinme_subdomain',
domain_name: domainArg,
bind_source: 'import',
});
console.log(chalk.green(`Bind success: ${domainArg}`));
const rootDomain = await getRootDomain();
console.log(
chalk.white(
`Visit (Pinme subdomain example): https://${domainArg}.${rootDomain}`,
),
);
} else {
void tracker.trackEvent(TRACK_EVENTS.domainBindFailed, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindFailed),
domain_type: 'pinme_subdomain',
domain_name: domainArg,
bind_source: 'import',
reason: 'pinme_bind_failed',
});
console.log(chalk.red('Binding failed. Please try again later.'));
}
}
console.log(chalk.green('\n🎉 import successful, program exit'));
}
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.importFailed, TRACK_PAGES.import, {
a: resolveTrackAction(TRACK_EVENTS.importFailed),
path_kind: pathKind,
has_domain: Boolean(domainArg),
reason: getTrackErrorReason(error),
});
printCliError(error, 'Import failed.');
}
process.exit(0);
}
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.importFailed, TRACK_PAGES.import, {
a: resolveTrackAction(TRACK_EVENTS.importFailed),
reason: getTrackErrorReason(error),
});
printCliError(error, 'Import failed.');
}
};
+238
View File
@@ -0,0 +1,238 @@
import dotenv from 'dotenv';
dotenv.config();
import { checkNodeVersion } from './utils/checkNodeVersion';
checkNodeVersion();
import { Command } from 'commander';
import chalk from 'chalk';
import figlet from 'figlet';
import { version } from '../package.json';
import upload from './upload';
import importFile from './importCar';
import exportFile from './exportCar';
import remove from './remove';
import { displayUploadHistory, clearUploadHistory } from './utils/history';
import setAppKeyCmd from './set-appkey';
import logoutCmd from './logout';
import showAppKeyCmd from './show-appkey';
import myDomainsCmd from './my-domains';
import walletBalanceCmd from './wallet-balance';
import bindCmd from './bind';
import loginCmd, { EnvOption } from './login';
import createCmd from './create';
import saveCmd from './save';
import updateDbCmd from './updateDb';
import updateWorkerCmd from './updateWorker';
import updateWebCmd from './updateWeb';
import deleteCmd from './delete';
interface SaveOptions {
name?: string;
domain?: string;
}
// display the ASCII art logo
function showBanner(): void {
console.log(
chalk.cyan(figlet.textSync('Pinme', { horizontalLayout: 'full' })),
);
console.log(chalk.cyan('A command-line tool for uploading files to IPFS\n'));
}
const program = new Command();
program
.name('pinme')
.version(version)
.option('-v, --version', 'output the current version');
program
.command('upload')
.description(
'upload a file or directory to IPFS. Supports --domain to bind after upload',
)
.option('-d, --domain <name>', 'Domain name to bind')
.option('--dns', 'Force DNS domain mode')
.action(() => upload());
program
.command('import')
.description("import a CAR file to IPFS. Supports --domain to bind after import")
.option('-d, --domain <name>', 'Pinme subdomain')
.action(() => importFile());
program
.command('export')
.description('export IPFS content as CAR file')
.option('-o, --output <path>', 'output file path for CAR file')
.action(() => exportFile());
program
.command('rm')
.description('remove a file from IPFS network')
.action(() => remove());
program
.command('login')
.description('Login via browser (opens web login page)')
.option('-e, --env <env>', 'Environment: test, prod')
.action((options: EnvOption) => loginCmd(options));
program
.command('set-appkey')
.description(
'Set AppKey for authentication (alternative to login command)',
)
.action(() => setAppKeyCmd());
program
.command('logout')
.description('log out and clear authentication')
.action(() => logoutCmd());
program
.command('show-appkey')
.alias('appkey')
.description('show current AppKey information (masked)')
.action(() => showAppKeyCmd());
program
.command('my-domains')
.alias('domain')
.description('List domains owned by current account')
.action(() => myDomainsCmd());
program
.command('wallet')
.alias('wallet-balance')
.alias('balance')
.description('Show current wallet balance')
.action(() => walletBalanceCmd());
program
.command('bind')
.description('Upload and bind to a domain (requires wallet balance)')
.option('-d, --domain <name>', 'Domain name to bind')
.option('--dns', 'Force DNS domain mode')
.action(() => bindCmd());
program
.command('create')
.description('Create a new project from template')
.argument('[name]', 'Project name')
.option('-f, --force', 'Overwrite if exists')
.action((name: string | undefined, options: { force?: boolean }) => createCmd({ name, force: options?.force }));
program
.command('save')
.description('Deploy the project (frontend + backend)')
.option('-d, --domain <name>', 'Bind a domain after frontend deploy')
.action((options: SaveOptions) => saveCmd(options));
program
.command('update-db')
.description('Execute database migration')
.action(() => updateDbCmd());
program
.command('update-worker')
.description('Execute worker migration')
.action(() => updateWorkerCmd());
program
.command('update-web')
.description('Deploy frontend to IPFS')
.action((options: { name?: string }) => updateWebCmd(options));
program
.command('delete')
.description('Delete a project (Worker, domain, D1 database)')
.argument('[name]', 'Project name')
.option('-f, --force', 'Skip confirmation')
.action((name: string | undefined, options: { force?: boolean }) => deleteCmd({ name, force: options?.force }));
program
.command('domain')
.description("Alias for 'my-domains' command")
.action(() => myDomainsCmd());
program
.command('list')
.description('show upload history')
.option(
'-l, --limit <number>',
'limit the number of records to show',
parseInt,
)
.option('-c, --clear', 'clear all upload history')
.action(async (options: { limit?: number; clear?: boolean }) => {
if (options.clear) {
clearUploadHistory();
} else {
await displayUploadHistory(options.limit || 10);
}
});
// add ls command as an alias for list command
program
.command('ls')
.description("alias for 'list' command")
.option(
'-l, --limit <number>',
'limit the number of records to show',
parseInt,
)
.option('-c, --clear', 'clear all upload history')
.action(async (options: { limit?: number; clear?: boolean }) => {
if (options.clear) {
clearUploadHistory();
} else {
await displayUploadHistory(options.limit || 10);
}
});
// add help command
program
.command('help')
.description('display help information')
.action(() => {
showBanner();
program.help();
});
// custom help output format
program.on('--help', () => {
console.log('');
console.log('Examples:');
console.log(' $ pinme upload');
console.log(' $ pinme upload <path> --domain <name>');
console.log(' $ pinme import');
console.log(' $ pinme import <path> --domain <name>');
console.log(' $ pinme export <cid> --output <path>');
console.log(' $ pinme rm <hash>');
console.log(' $ pinme login');
console.log(' $ pinme set-appkey <AppKey>');
console.log(' $ pinme show-appkey');
console.log(' $ pinme logout');
console.log(' $ pinme my-domains');
console.log(' $ pinme domain');
console.log(' $ pinme list -l 5');
console.log(' $ pinme ls');
console.log(' $ pinme help');
console.log('');
console.log(
'For more information, visit: https://github.com/glitternetwork/pinme',
);
});
// parse the command line arguments
program.parse(process.argv);
// If no arguments provided, show banner and help
if (process.argv.length === 2) {
showBanner();
program.help();
}
+74
View File
@@ -0,0 +1,74 @@
import chalk from 'chalk';
import { WebLoginManager } from './utils/webLogin';
import { getDeviceId } from './utils/getDeviceId';
import { bindAnonymousDevice } from './utils/pinmeApi';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
export interface EnvOption {
env?: string;
}
const ENV_URLS: Record<string, string> = {
dev: 'http://localhost:5173',
test: 'http://test-pinme.pinit.eth.limo',
prod: 'https://pinme.eth.limo',
};
export default async function loginCmd(options: EnvOption = {}): Promise<void> {
const env = (options.env || 'prod').toLowerCase();
try {
// Determine web base URL based on env
let webBaseUrl: string | undefined;
// 默认使用 prod 环境
if (ENV_URLS[env]) {
webBaseUrl = ENV_URLS[env];
console.log(chalk.blue(`Using ${env} environment: ${webBaseUrl}`));
} else {
console.log(
chalk.yellow(
`Unknown environment: ${options.env}. Using default prod.`,
),
);
webBaseUrl = ENV_URLS.prod;
console.log(chalk.blue(`Using prod environment: ${webBaseUrl}`));
}
// Perform login with custom webBaseUrl if specified
const manager = new WebLoginManager({ webBaseUrl });
await manager.login();
// Merge anonymous history
console.log(chalk.blue('\nMerging history...'));
const deviceId = getDeviceId();
const ok = await bindAnonymousDevice(deviceId);
if (ok) {
console.log(chalk.green('History merged to your account'));
}
void tracker.trackEvent(TRACK_EVENTS.cliLoginSuccess, TRACK_PAGES.login, {
a: resolveTrackAction(TRACK_EVENTS.cliLoginSuccess),
env,
source: 'cli',
has_token_address: true,
merged_anonymous_history: ok,
});
// Exit the process after successful login
process.exit(0);
} catch (e: any) {
void tracker.trackEvent(TRACK_EVENTS.cliLoginFailed, TRACK_PAGES.login, {
a: resolveTrackAction(TRACK_EVENTS.cliLoginFailed),
env,
source: 'cli',
reason: getTrackErrorReason(e),
});
console.log(chalk.red(`\nLogin failed: ${e?.message || e}`));
process.exit(1);
}
}
+50
View File
@@ -0,0 +1,50 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import { clearAuthToken, getAuthConfig } from './utils/webLogin';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
export default async function logoutCmd(): Promise<void> {
try {
// Check if user is logged in
const auth = getAuthConfig();
if (!auth) {
console.log(chalk.yellow('No active session found. You are already logged out.'));
return;
}
// Confirm logout
const answer = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to log out? (Current address: ${auth.address})`,
default: false,
},
]);
if (!answer.confirm) {
console.log(chalk.blue('Logout cancelled.'));
return;
}
// Clear auth token
clearAuthToken();
void tracker.trackEvent(TRACK_EVENTS.logoutSuccess, TRACK_PAGES.auth, {
a: resolveTrackAction(TRACK_EVENTS.logoutSuccess),
had_session: true,
});
console.log(chalk.green('Successfully logged out.'));
console.log(chalk.gray(`Address ${auth.address} has been removed from local storage.`));
} catch (e: any) {
void tracker.trackEvent(TRACK_EVENTS.logoutFailed, TRACK_PAGES.auth, {
a: resolveTrackAction(TRACK_EVENTS.logoutFailed),
reason: getTrackErrorReason(e),
});
console.log(chalk.red(`Failed to logout: ${e?.message || e}`));
}
}
+45
View File
@@ -0,0 +1,45 @@
import chalk from 'chalk';
import dayjs from 'dayjs';
import { printCliError } from './utils/cliError';
import { getMyDomains } from './utils/pinmeApi';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
export default async function myDomainsCmd(): Promise<void> {
try {
const list = await getMyDomains();
void tracker.trackEvent(TRACK_EVENTS.myDomainsSuccess, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.myDomainsSuccess),
domain_count: list.length,
});
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) {
void tracker.trackEvent(TRACK_EVENTS.myDomainsFailed, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.myDomainsFailed),
reason: getTrackErrorReason(e),
});
printCliError(e, 'Failed to fetch domains.');
}
}
+217
View File
@@ -0,0 +1,217 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import figlet from 'figlet';
import { removeFromIpfs } from './utils/removeFromIpfs';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
interface RemoveOptions {
[key: string]: any;
}
import { checkNodeVersion } from './utils/checkNodeVersion';
checkNodeVersion();
// Validate IPFS hash format
function isValidIPFSHash(hash: string): boolean {
// IPFS v0 hash (Qm...)
const v0Pattern = /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/;
// IPFS v1 hash (bafy...)
const v1Pattern = /^bafy[a-z2-7]{50,}$/;
// IPFS v1 hash (bafk...)
const v1kPattern = /^bafk[a-z2-7]{50,}$/;
// IPFS v1 hash (bafybe...)
const v1bePattern = /^bafybe[a-z2-7]{50,}$/;
return v0Pattern.test(hash) || v1Pattern.test(hash) || v1kPattern.test(hash) || v1bePattern.test(hash);
}
// Validate subname format (simple alphanumeric combination)
function isValidSubname(subname: string): boolean {
const subnamePattern = /^[a-zA-Z0-9]{6,12}$/;
return subnamePattern.test(subname);
}
// Parse different input formats
function parseInput(input: string): { type: 'hash' | 'subname'; value: string } | null {
const trimmedInput = input.trim();
// Case 1: Full URL with hash - https://bafybeigthbkdv2ufll47r7e7f5z4c3vubyggxwotl52parmy3d3abt6ztu.pinme.eth.limo
const hashUrlPattern = /https?:\/\/([a-z0-9]{50,})\.pinme\.dev/i;
const hashUrlMatch = trimmedInput.match(hashUrlPattern);
if (hashUrlMatch) {
const hash = hashUrlMatch[1];
if (isValidIPFSHash(hash)) {
return { type: 'hash', value: hash };
}
}
// Case 2: Direct hash - bafybeigthbkdv2ufll47r7e7f5z4c3vubyggxwotl52parmy3d3abt6ztu
if (isValidIPFSHash(trimmedInput)) {
return { type: 'hash', value: trimmedInput };
}
// Case 3: Subname URL - https://3abt6ztu.<root-domain>
const subnameUrlPattern = /https?:\/\/([a-zA-Z0-9]{6,12})\.[a-zA-Z0-9.-]+/i;
const subnameUrlMatch = trimmedInput.match(subnameUrlPattern);
if (subnameUrlMatch) {
const subname = subnameUrlMatch[1];
if (isValidSubname(subname)) {
return { type: 'subname', value: subname };
}
}
// Case 4: Direct subname - 3abt6ztu
if (isValidSubname(trimmedInput)) {
return { type: 'subname', value: trimmedInput };
}
return null;
}
export default async (options?: RemoveOptions): Promise<void> => {
try {
console.log(
figlet.textSync('PINME', {
font: 'Standard',
horizontalLayout: 'default',
verticalLayout: 'default',
width: 180,
whitespaceBreak: true,
}),
);
// If parameter is passed, remove directly, e.g.: pinme rm <hash>
const argHash = process.argv[3];
if (argHash && !argHash.startsWith('-')) {
const parsedInput = parseInput(argHash);
if (!parsedInput) {
console.log(chalk.red(`Invalid input format: ${argHash}`));
console.log(chalk.yellow('Supported formats:'));
console.log(chalk.yellow(' - IPFS hash: bafybeig...'));
console.log(chalk.yellow(' - Subname: 3abt6ztu'));
console.log(chalk.yellow(' - Subname URL: https://3abt6ztu.<root-domain>'));
return;
}
try {
const success = await removeFromIpfs(parsedInput.value, parsedInput.type);
if (success) {
void tracker.trackEvent(TRACK_EVENTS.removeSuccess, TRACK_PAGES.remove, {
a: resolveTrackAction(TRACK_EVENTS.removeSuccess),
input_type: parsedInput.type,
});
console.log(
chalk.cyan(
figlet.textSync('Successful', { horizontalLayout: 'full' }),
),
);
}
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.removeFailed, TRACK_PAGES.remove, {
a: resolveTrackAction(TRACK_EVENTS.removeFailed),
input_type: parsedInput.type,
reason: getTrackErrorReason(error),
});
console.error(chalk.red(`Error: ${error.message}`));
}
return;
}
// Interactive removal
console.log(chalk.yellow('⚠️ Warning: This action will permanently remove the content from IPFS network'));
console.log(chalk.yellow('⚠️ Make sure you have the correct IPFS hash'));
console.log('');
const confirmAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Do you want to continue?',
default: false,
},
]);
if (!confirmAnswer.confirm) {
console.log(chalk.yellow('Operation cancelled'));
return;
}
const answer = await inquirer.prompt([
{
type: 'input',
name: 'input',
message: 'Enter IPFS hash, subname, or URL to remove:',
validate: (input: string) => {
if (!input.trim()) {
return 'Please enter an IPFS hash, subname, or URL';
}
const parsedInput = parseInput(input.trim());
if (!parsedInput) {
return 'Invalid format. Supported: IPFS hash, full URL (*), subname, or subname URL (*.<root-domain>)';
}
return true;
},
},
]);
if (answer.input) {
const parsedInput = parseInput(answer.input.trim());
if (!parsedInput) {
console.log(chalk.red('Invalid input format'));
return;
}
// Final confirmation
const finalConfirm = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to remove ${parsedInput.type === 'hash' ? 'hash' : 'subname'}: ${parsedInput.value}?`,
default: false,
},
]);
if (!finalConfirm.confirm) {
console.log(chalk.yellow('Operation cancelled'));
return;
}
try {
const success = await removeFromIpfs(parsedInput.value, parsedInput.type);
if (success) {
void tracker.trackEvent(TRACK_EVENTS.removeSuccess, TRACK_PAGES.remove, {
a: resolveTrackAction(TRACK_EVENTS.removeSuccess),
input_type: parsedInput.type,
});
console.log(
chalk.cyan(
figlet.textSync('Successful', { horizontalLayout: 'full' }),
),
);
}
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.removeFailed, TRACK_PAGES.remove, {
a: resolveTrackAction(TRACK_EVENTS.removeFailed),
input_type: parsedInput.type,
reason: getTrackErrorReason(error),
});
console.error(chalk.red(`Error: ${error.message}`));
}
}
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.removeFailed, TRACK_PAGES.remove, {
a: resolveTrackAction(TRACK_EVENTS.removeFailed),
reason: getTrackErrorReason(error),
});
console.error(chalk.red(`Error executing remove command: ${error.message}`));
console.error(error.stack);
}
};
+615
View File
@@ -0,0 +1,615 @@
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs-extra';
import path from 'path';
import axios from 'axios';
import { execSync } from 'child_process';
import { getAuthHeaders } from './utils/webLogin';
import {
INSTALL_LOG_FILE,
INSTALL_EXITCODE_FILE,
INSTALL_PID_FILE,
installProjectDependencies,
readBackgroundInstallStatus,
readBackgroundInstallLogTail,
stopBackgroundInstall,
} from './utils/installProjectDependencies';
import {
bindDnsDomainV4,
bindPinmeDomain,
getRootDomain,
} from './utils/pinmeApi';
import {
isDnsDomain,
normalizeDomain,
validateDnsDomain,
} from './utils/domainValidator';
import {
CliError,
createApiError,
createCommandError,
createConfigError,
printCliError,
} from './utils/cliError';
import { APP_CONFIG, getPinmeApiUrl } from './utils/config';
import { uploadPath } from './services/uploadService';
import { printHighlightedUrl } from './utils/urlDisplay';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
const PROJECT_DIR = process.cwd();
interface SaveOptions {
projectName?: string;
name?: string;
domain?: string;
}
// ============ 工具函数 ============
function loadConfig() {
const configPath = path.join(PROJECT_DIR, 'pinme.toml');
if (!fs.existsSync(configPath)) {
throw createConfigError('`pinme.toml` not found in the current directory.', [
'Run this command from the Pinme project root.',
'If the project has not been initialized yet, create or restore `pinme.toml` first.',
]);
}
const configContent = fs.readFileSync(configPath, 'utf-8');
const projectNameMatch = configContent.match(/project_name\s*=\s*"([^"]+)"/);
return {
project_name: projectNameMatch?.[1] || '',
};
}
function getProjectManagementUrl(projectName: string): string {
return `${APP_CONFIG.projectPeviewUrl}${projectName}`;
}
// ============ 后端部署 ============
function getMetadata() {
const metadataPath = path.join(PROJECT_DIR, 'backend', 'metadata.json');
if (!fs.existsSync(metadataPath)) {
console.log(chalk.yellow(' Warning: metadata.json not found, using empty metadata'));
return {};
}
return fs.readJsonSync(metadataPath);
}
function buildWorker() {
console.log(chalk.blue('Building worker...'));
try {
execSync('npm run build:worker', {
cwd: PROJECT_DIR,
stdio: 'inherit',
});
console.log(chalk.green('Worker built'));
} catch (error: any) {
if (isMissingDependencyError(error)) {
throw dependenciesMissingError(
'Worker build failed because a required CLI (e.g. `wrangler`) was not found. Project dependencies are missing or incomplete.',
);
}
throw createCommandError('worker build', 'npm run build:worker', error, [
'Fix the build error shown above, then rerun `pinme save`.',
]);
}
}
// ============ 依赖检查 ============
/**
* Build a clear, machine-readable error telling the caller (an AI agent or a
* human) that dependencies are missing/incomplete and exactly how to fix it.
* `pinme save` no longer installs dependencies itself — `pinme create` kicks off
* the install in the background — so this is the single source of truth for the
* "deps not ready" condition, whether detected up front or during a build.
*/
function dependenciesMissingError(summary: string, logTail?: string): CliError {
const installLogPath = path.join(PROJECT_DIR, '.pinme-install.log');
const suggestions = [
'Run `npm install` in the project root, wait for it to finish, then rerun `pinme save`.',
];
if (fs.existsSync(installLogPath)) {
suggestions.push(
`Background install log: ${installLogPath}`,
);
}
const error = createConfigError(summary, suggestions);
const trimmedTail = logTail?.trim();
if (trimmedTail) {
error.details = [
...error.details,
'Install log (tail):',
...trimmedTail.split('\n').slice(-20),
];
}
return error;
}
/**
* Detect a build failure that was actually caused by missing dependencies —
* typically a CLI such as `wrangler` or `vite` not being on PATH (exit code 127
* / "command not found"), or a module that could not be resolved.
*/
function isMissingDependencyError(error: any): boolean {
const exitCode = error?.status ?? error?.code;
if (exitCode === 127) {
return true;
}
const haystack = [error?.message, error?.stderr, error?.stdout]
.map((value) => String(value || ''))
.join(' ')
.toLowerCase();
return /command not found|not found|is not recognized|cannot find module|cannot find package/.test(haystack);
}
/** Required build CLIs: worker build needs `wrangler`, frontend build needs `vite`. */
function dependenciesPresent(): boolean {
return hasLocalBinary('wrangler') && hasLocalBinary('vite');
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const WAIT_FOR_INSTALL_TIMEOUT_MS = 15 * 1000;
const WAIT_POLL_INTERVAL_MS = 2000;
/**
* Ensure dependencies are ready before building.
*
* `pinme save` no longer installs anything itself — `pinme create` kicks off the
* install in the background. So here we:
* - return immediately if the build CLIs are already present;
* - otherwise inspect the background-install markers and, if it is still
* running, WAIT for it to finish with a live spinner (so the user isn't left
* guessing when they can save);
* - throw a clear, machine-readable error if the install failed, timed out, or
* was never started — telling the caller exactly to run `npm install`.
*/
async function ensureDependenciesReady(): Promise<void> {
if (dependenciesPresent()) {
console.log(chalk.gray('Dependencies are installed.'));
return;
}
const initial = readBackgroundInstallStatus(PROJECT_DIR);
if (initial.status === 'idle') {
await installDependenciesInForeground();
return;
}
if (initial.status === 'failed') {
console.log(chalk.yellow(
`Background dependency install failed with exit code ${initial.exitCode}. Retrying in this terminal...`,
));
await installDependenciesInForeground();
return;
}
if (initial.status === 'interrupted') {
console.log(chalk.yellow('Background dependency install was interrupted. Continuing in this terminal...'));
await installDependenciesInForeground();
return;
}
// status === 'running' (or 'success' but CLIs not visible yet) → wait briefly,
// then take over in the foreground so the user can see real npm output.
const spinner = ora('Waiting briefly for background dependency install...').start();
const startedAt = Date.now();
while (true) {
await sleep(WAIT_POLL_INTERVAL_MS);
if (dependenciesPresent()) {
spinner.succeed('Dependencies installed.');
return;
}
const state = readBackgroundInstallStatus(PROJECT_DIR);
if (state.status === 'failed') {
spinner.fail('Background dependency install failed.');
console.log(chalk.yellow(
`Retrying dependency install in this terminal after exit code ${state.exitCode}...`,
));
await installDependenciesInForeground();
return;
}
if (state.status === 'interrupted') {
spinner.fail('Background dependency install was interrupted.');
await installDependenciesInForeground();
return;
}
if (state.status === 'success') {
// Install finished cleanly; trust it even if our CLI probe missed the bins.
spinner.succeed('Dependencies installed.');
return;
}
const elapsedMs = Date.now() - startedAt;
if (elapsedMs > WAIT_FOR_INSTALL_TIMEOUT_MS) {
spinner.warn('Background dependency install is not ready yet; continuing in this terminal.');
await stopBackgroundInstall(PROJECT_DIR);
await installDependenciesInForeground();
return;
}
spinner.text = `Waiting briefly for background dependency install... (${Math.round(elapsedMs / 1000)}s)`;
}
}
async function installDependenciesInForeground(): Promise<void> {
console.log(chalk.blue('Installing project dependencies...'));
try {
await stopBackgroundInstall(PROJECT_DIR);
await installProjectDependencies(PROJECT_DIR);
} catch (error: any) {
throw dependenciesMissingError(
'Project dependency install failed.',
readBackgroundInstallLogTail(PROJECT_DIR) || error?.message,
);
}
if (!dependenciesPresent()) {
throw dependenciesMissingError(
'Project dependencies were installed, but required build CLIs are still missing.',
readBackgroundInstallLogTail(PROJECT_DIR),
);
}
console.log(chalk.green('Project dependencies installed.'));
}
/**
* Check whether an npm-installed binary is available in any of the workspace
* `node_modules/.bin` directories (handles Windows `.cmd`/`.exe` shims too).
*/
/** Remove the background-install marker files once they are no longer needed. */
function cleanupInstallMarkers(): void {
try {
fs.removeSync(path.join(PROJECT_DIR, INSTALL_LOG_FILE));
fs.removeSync(path.join(PROJECT_DIR, INSTALL_EXITCODE_FILE));
fs.removeSync(path.join(PROJECT_DIR, INSTALL_PID_FILE));
} catch {
// Best-effort cleanup; never fail the command over leftover marker files.
}
}
function hasLocalBinary(name: string): boolean {
const binDirs = [
path.join(PROJECT_DIR, 'node_modules', '.bin'),
path.join(PROJECT_DIR, 'backend', 'node_modules', '.bin'),
path.join(PROJECT_DIR, 'frontend', 'node_modules', '.bin'),
];
const candidates = process.platform === 'win32'
? [name, `${name}.cmd`, `${name}.exe`, `${name}.ps1`]
: [name];
return binDirs.some((dir) => candidates.some((candidate) => fs.existsSync(path.join(dir, candidate))));
}
function getBuiltWorker(): { workerJsPath: string; modulePaths: string[] } {
const distWorkerDir = path.join(PROJECT_DIR, 'dist-worker');
if (!fs.existsSync(distWorkerDir)) {
throw createConfigError('Built worker output not found: `dist-worker/`.', [
'Make sure `npm run build:worker` completed successfully.',
]);
}
const workerJsPath = path.join(distWorkerDir, 'worker.js');
if (!fs.existsSync(workerJsPath)) {
throw createConfigError('Built worker entry file not found: `dist-worker/worker.js`.', [
'Check the worker build output and bundler config.',
]);
}
const modulePaths: string[] = [];
const files = fs.readdirSync(distWorkerDir);
for (const file of files) {
if (file.endsWith('.js') && file !== 'worker.js') {
modulePaths.push(path.join(distWorkerDir, file));
}
}
return { workerJsPath, modulePaths };
}
function getSqlFiles(): string[] {
const sqlDir = path.join(PROJECT_DIR, 'db');
if (!fs.existsSync(sqlDir)) {
return [];
}
const files = fs.readdirSync(sqlDir)
.filter(f => f.endsWith('.sql'))
.sort();
return files.map(f => path.join(sqlDir, f));
}
async function saveWorker(workerJsPath: string, modulePaths: string[], sqlFiles: string[], metadata: any, projectName: string) {
console.log(chalk.blue('Saving worker to platform...'));
console.log(chalk.gray(`Project: ${projectName}`));
console.log(chalk.gray(`workerJsPath: ${workerJsPath}`));
console.log(chalk.gray(`modulePaths: ${modulePaths}`));
console.log(chalk.gray(`sqlFiles: ${sqlFiles}`));
console.log(chalk.gray(`metadata: ${metadata}`));
const apiUrl = `${getPinmeApiUrl('/save_worker')}?project_name=${encodeURIComponent(projectName)}`;
const headers = getAuthHeaders();
console.log(chalk.gray(`API URL: ${apiUrl}`));
try {
const FormData = (await import('formdata-node')).FormData;
const Blob = (await import('formdata-node')).Blob;
const formData = new FormData() as any;
formData.append('metadata', new Blob([JSON.stringify(metadata)], {
type: 'application/json',
}), 'metadata.json');
// worker.js
const workerCode = fs.readFileSync(workerJsPath, 'utf-8');
formData.append('worker.js', new Blob([workerCode], {
type: 'application/javascript+module',
}), 'worker.js');
// Other modules
for (const modulePath of modulePaths) {
const filename = path.basename(modulePath);
const content = fs.readFileSync(modulePath, 'utf-8');
formData.append(filename, new Blob([content], {
type: 'application/javascript+module',
}), filename);
}
for (const sqlFile of sqlFiles) {
const filename = path.basename(sqlFile);
const content = fs.readFileSync(sqlFile, 'utf-8');
formData.append('sql_file', new Blob([content], {
type: 'application/sql',
}), filename);
console.log(chalk.gray(` Including SQL: ${filename}`));
}
const response = await axios.put(apiUrl, formData, {
headers: { ...headers },
timeout: 120000,
});
console.log(chalk.gray(` Response: ${JSON.stringify(response.data)}`));
if (response.data) {
console.log(chalk.green('Worker saved'));
if (response.data?.data?.sql_results) {
for (const result of response.data.data.sql_results) {
console.log(chalk.gray(` SQL ${result.filename}: ${result.status}`));
}
}
} else {
throw createApiError('worker save', { response: { data: response.data } }, [
`Project: ${projectName}`,
`Endpoint: ${apiUrl}`,
], [
'Verify the project exists and your account has permission to update it.',
]);
}
} catch (error: any) {
throw createApiError('worker save', error, [
`Project: ${projectName}`,
`Endpoint: ${apiUrl}`,
], [
'Check whether backend metadata, SQL files, or worker bundle contains invalid content.',
]);
}
}
// ============ 前端部署 ============
function buildFrontend() {
console.log(chalk.blue('Building frontend...'));
try {
execSync('npm run build:frontend', {
cwd: PROJECT_DIR,
stdio: 'inherit',
});
console.log(chalk.green('Frontend built'));
} catch (error: any) {
if (isMissingDependencyError(error)) {
throw dependenciesMissingError(
'Frontend build failed because a required CLI (e.g. `vite`) was not found. Project dependencies are missing or incomplete.',
);
}
throw createCommandError('frontend build', 'npm run build:frontend', error, [
'Fix the frontend build error shown above, then rerun `pinme save`.',
]);
}
}
function updateFrontendUrlInConfig(configPath: string, frontendUrl: string): void {
let config = fs.readFileSync(configPath, 'utf-8');
if (config.includes('frontend_url')) {
config = config.replace(
/frontend_url\s*=\s*"[^"]*"/,
`frontend_url = "${frontendUrl}"`,
);
} else {
config = config.replace(
/(project_name\s*=\s*"[^"]*"\n)/,
`$1frontend_url = "${frontendUrl}"\n`,
);
}
fs.writeFileSync(configPath, config);
}
async function deployFrontend(projectName: string): Promise<{ contentHash: string; publicUrl: string }> {
console.log(chalk.blue('Deploying frontend to IPFS...'));
try {
const headers = getAuthHeaders();
const uploadResult = await uploadPath(path.join(PROJECT_DIR, 'frontend', 'dist'), {
action: 'project_save',
projectName,
uid: headers['token-address'],
});
updateFrontendUrlInConfig(path.join(PROJECT_DIR, 'pinme.toml'), uploadResult.publicUrl);
return {
contentHash: uploadResult.contentHash,
publicUrl: uploadResult.publicUrl,
};
} catch (error: any) {
throw createCommandError('frontend deploy', 'upload frontend/dist', error, [
'Make sure `frontend/dist` exists and the upload API is reachable.',
]);
}
}
async function bindFrontendDomain(
domain: string,
contentHash: string,
projectName: string,
headers: Record<string, string>,
): Promise<string> {
const displayDomain = normalizeDomain(domain);
const isDns = isDnsDomain(displayDomain);
if (isDns) {
const validation = validateDnsDomain(displayDomain);
if (!validation.valid) {
throw createConfigError(validation.message || 'Invalid domain format.', [
'Use a complete domain like `example.com` for DNS binding.',
]);
}
}
if (isDns) {
const dnsResult = await bindDnsDomainV4(
displayDomain,
contentHash,
headers['token-address'],
headers['authentication-tokens'],
projectName,
);
if (dnsResult.code !== 200) {
throw new Error(dnsResult.msg || 'DNS binding failed');
}
return `https://${displayDomain}`;
}
const ok = await bindPinmeDomain(displayDomain, contentHash, projectName);
if (!ok) {
throw new Error('Pinme subdomain binding failed');
}
const rootDomain = await getRootDomain();
return `https://${displayDomain}.${rootDomain}`;
}
// ============ 主函数 ============
/**
* Save and deploy: build + upload worker + deploy frontend to IPFS
*/
export default async function saveCmd(options: SaveOptions): Promise<void> {
try {
// Check if user is logged in
const headers = getAuthHeaders();
if (!headers['authentication-tokens'] || !headers['token-address']) {
throw createConfigError('No valid local login session was found.', [
'Run `pinme login` and retry.',
]);
}
// Copy token to project directory for sub-commands
const projectDir = options.projectName || options.name ? path.join(PROJECT_DIR, options.projectName || options.name!) : PROJECT_DIR;
const tokenFileSrc = path.join(PROJECT_DIR, '.token.json');
const tokenFileDst = path.join(projectDir, '.token.json');
if (fs.existsSync(tokenFileSrc) && !fs.existsSync(tokenFileDst)) {
fs.copySync(tokenFileSrc, tokenFileDst);
}
console.log(chalk.blue('Deploying to platform...\n'));
console.log(chalk.gray(`Project dir: ${PROJECT_DIR}`));
const config = loadConfig();
const projectName = config.project_name;
if (!projectName) {
throw createConfigError('`project_name` is missing in `pinme.toml`.', [
'Set `project_name = "your-project-name"` in `pinme.toml`.',
]);
}
console.log(chalk.gray(`Project: ${projectName}`));
const apiUrl = `${getPinmeApiUrl('/save_worker')}?project_name=${encodeURIComponent(projectName)}`;
console.log(chalk.gray(`API URL: ${apiUrl}`));
// Backend: build + save
console.log(chalk.blue('\n--- Backend ---'));
await ensureDependenciesReady();
buildWorker();
const metadata = getMetadata();
const { workerJsPath, modulePaths } = getBuiltWorker();
console.log(chalk.gray(`Worker JS: ${workerJsPath}`));
console.log(chalk.gray(`Module paths: ${JSON.stringify(modulePaths)}`));
const sqlFiles = getSqlFiles();
console.log(chalk.gray(`SQL files: ${JSON.stringify(sqlFiles)}`));
await saveWorker(workerJsPath, modulePaths, sqlFiles, metadata, projectName);
// Frontend: build + deploy
console.log(chalk.blue('\n--- Frontend ---'));
buildFrontend();
const frontendResult = await deployFrontend(projectName);
let finalFrontendUrl = frontendResult.publicUrl;
if (options.domain) {
finalFrontendUrl = await bindFrontendDomain(
options.domain,
frontendResult.contentHash,
projectName,
headers,
);
}
console.log(chalk.blue('\n--- Access ---'));
printHighlightedUrl('Frontend URL', finalFrontendUrl, 'primary');
printHighlightedUrl(
'Project Management URL',
getProjectManagementUrl(projectName),
'management',
);
console.log(chalk.green('\nDeployment complete.'));
// Dependencies are installed and the deploy succeeded; the install markers
// are no longer useful, so clean them up.
cleanupInstallMarkers();
void tracker.trackEvent(TRACK_EVENTS.projectSaveSuccess, TRACK_PAGES.deploy, {
a: resolveTrackAction(TRACK_EVENTS.projectSaveSuccess),
project_name: projectName,
has_domain: Boolean(options.domain),
domain_type: options.domain
? (isDnsDomain(options.domain) ? 'dns' : 'pinme_subdomain')
: undefined,
});
process.exit(0);
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.projectSaveFailed, TRACK_PAGES.deploy, {
a: resolveTrackAction(TRACK_EVENTS.projectSaveFailed),
project_name: options.projectName || options.name,
has_domain: Boolean(options.domain),
reason: getTrackErrorReason(error),
});
printCliError(error, 'Save failed.');
process.exit(1);
}
}
+155
View File
@@ -0,0 +1,155 @@
import CryptoJS from 'crypto-js';
import { getUid } from '../utils/getDeviceId';
import uploadToIpfsSplit from '../utils/uploadToIpfsSplit';
import type { UploadAction } from '../utils/uploadToIpfsSplit';
import { APP_CONFIG } from '../utils/config';
import { getRootDomain } from '../utils/pinmeApi';
import { getAuthConfig } from '../utils/webLogin';
export interface UploadServiceOptions {
action?: UploadAction;
importAsCar?: boolean;
projectName?: string;
uid?: string;
}
export interface UploadServiceResult {
contentHash: string;
shortUrl?: string;
pinmeUrl?: string;
dnsUrl?: string;
publicUrl: string;
managementUrl: string;
}
function encryptHash(
contentHash: string,
key: string | undefined,
uid?: string,
): string {
if (!key) {
return contentHash;
}
const combined = uid ? `${contentHash}-${uid}` : contentHash;
const encrypted = CryptoJS.RC4.encrypt(combined, key).toString();
return encrypted.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function formatShortUrl(shortUrl?: string): Promise<string | undefined> {
if (!shortUrl) {
return undefined;
}
const normalized = shortUrl.trim();
if (!normalized) {
return undefined;
}
if (/^https?:\/\//.test(normalized)) {
return normalized;
}
if (normalized.includes('.')) {
return `https://${normalized}`;
}
const rootDomain = await getRootDomain();
return `https://${normalized}.${rootDomain}`;
}
async function formatPreferredUrl(
value?: string,
options?: { appendRootDomain?: boolean },
): Promise<string | undefined> {
if (!value) {
return undefined;
}
const normalized = value.trim();
if (!normalized) {
return undefined;
}
const withProtocol = /^https?:\/\//.test(normalized)
? normalized
: `https://${normalized}`;
try {
const url = new URL(withProtocol);
if (!options?.appendRootDomain || url.hostname.includes('.')) {
return url.toString().replace(/\/$/, '');
}
const rootDomain = await getRootDomain();
url.hostname = `${url.hostname}.${rootDomain}`;
return url.toString().replace(/\/$/, '');
} catch {
return withProtocol.replace(/\/$/, '');
}
}
export async function resolveUploadUrls(
contentHash: string,
urls?: {
dnsUrl?: string;
pinmeUrl?: string;
shortUrl?: string;
},
projectName?: string,
uid?: string,
): Promise<{ publicUrl: string; managementUrl: string }> {
const resolvedUid = uid?.trim() || getUid();
const encryptedCID = encryptHash(contentHash, APP_CONFIG.secretKey, resolvedUid);
const normalizedProjectName = projectName?.trim();
const managementUrl = normalizedProjectName
? `${APP_CONFIG.projectPeviewUrl}${normalizedProjectName}`
: `${APP_CONFIG.ipfsPreviewUrl}${encryptedCID}`;
const publicUrl =
(await formatPreferredUrl(urls?.dnsUrl)) ||
(await formatPreferredUrl(urls?.pinmeUrl, { appendRootDomain: true })) ||
(await formatShortUrl(urls?.shortUrl)) ||
managementUrl;
return {
publicUrl,
managementUrl,
};
}
export async function uploadPath(
targetPath: string,
options: UploadServiceOptions = {},
): Promise<UploadServiceResult> {
const authConfig = getAuthConfig();
if (!authConfig) {
throw new Error('Please login first. Run: pinme login');
}
const result = await uploadToIpfsSplit(targetPath, {
action: options.action,
importAsCar: options.importAsCar,
projectName: options.projectName,
uid: options.uid || authConfig.address,
});
if (!result?.contentHash) {
throw new Error('Upload failed: no content hash returned');
}
const urls = await resolveUploadUrls(
result.contentHash,
{
dnsUrl: result.dnsUrl,
pinmeUrl: result.pinmeUrl,
shortUrl: result.shortUrl,
},
options.projectName,
options.uid,
);
return {
contentHash: result.contentHash,
shortUrl: result.shortUrl,
pinmeUrl: result.pinmeUrl,
dnsUrl: result.dnsUrl,
publicUrl: urls.publicUrl,
managementUrl: urls.managementUrl,
};
}
+55
View File
@@ -0,0 +1,55 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import { setAuthToken } from './utils/webLogin';
import { getDeviceId } from './utils/getDeviceId';
import { bindAnonymousDevice } from './utils/pinmeApi';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
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.'));
}
void tracker.trackEvent(TRACK_EVENTS.appKeySetSuccess, TRACK_PAGES.auth, {
a: resolveTrackAction(TRACK_EVENTS.appKeySetSuccess),
merged_anonymous_history: ok,
has_token_address: Boolean(saved.address),
});
} catch (e: any) {
void tracker.trackEvent(TRACK_EVENTS.appKeySetFailed, TRACK_PAGES.auth, {
a: resolveTrackAction(TRACK_EVENTS.appKeySetFailed),
reason: getTrackErrorReason(e),
});
console.log(chalk.red(`Failed to set AppKey: ${e?.message || e}`));
}
}
+56
View File
@@ -0,0 +1,56 @@
import chalk from 'chalk';
import { getAuthConfig } from './utils/webLogin';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
export default function showAppKeyCmd(): void {
try {
const auth = getAuthConfig();
if (!auth) {
void tracker.trackEvent(TRACK_EVENTS.appKeyShownFailed, TRACK_PAGES.auth, {
a: resolveTrackAction(TRACK_EVENTS.appKeyShownFailed),
reason: 'no_appkey_found',
});
console.log(chalk.yellow('No AppKey found. Please set your AppKey first.'));
console.log(chalk.gray('Run: pinme set-appkey <AppKey>'));
return;
}
// Display address (safe to show)
console.log(chalk.green('Current AppKey Information:'));
console.log(chalk.cyan(` Address: ${auth.address}`));
// Show token with masking (show first 8 chars and last 4 chars)
const token = auth.token;
if (token.length > 12) {
const maskedToken = `${token.substring(0, 8)}${'*'.repeat(token.length - 12)}${token.substring(token.length - 4)}`;
console.log(chalk.cyan(` Token: ${maskedToken}`));
} else {
// If token is too short, just show asterisks
console.log(chalk.cyan(` Token: ${'*'.repeat(token.length)}`));
}
// Show full combined format (AppKey)
const combined = `${auth.address}-${auth.token}`;
if (combined.length > 20) {
const maskedAppKey = `${combined.substring(0, 12)}${'*'.repeat(combined.length - 16)}${combined.substring(combined.length - 4)}`;
console.log(chalk.cyan(` AppKey: ${maskedAppKey}`));
} else {
console.log(chalk.cyan(` AppKey: ${'*'.repeat(combined.length)}`));
}
void tracker.trackEvent(TRACK_EVENTS.appKeyShownSuccess, TRACK_PAGES.auth, {
a: resolveTrackAction(TRACK_EVENTS.appKeyShownSuccess),
has_token_address: Boolean(auth.address),
});
} catch (e: any) {
void tracker.trackEvent(TRACK_EVENTS.appKeyShownFailed, TRACK_PAGES.auth, {
a: resolveTrackAction(TRACK_EVENTS.appKeyShownFailed),
reason: getTrackErrorReason(e),
});
console.log(chalk.red(`Failed to show AppKey: ${e?.message || e}`));
}
}
+210
View File
@@ -0,0 +1,210 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import axios from 'axios';
import { getAuthHeaders } from './utils/webLogin';
import {
createApiError,
createConfigError,
printCliError,
} from './utils/cliError';
import { getPinmeApiUrl } from './utils/config';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
const PROJECT_DIR = process.cwd();
interface UpdateDbOptions {
projectName?: string;
name?: string;
}
// ============ 工具函数 ============
function loadConfig() {
const configPath = path.join(PROJECT_DIR, 'pinme.toml');
if (!fs.existsSync(configPath)) {
throw createConfigError('`pinme.toml` not found in the current directory.', [
'Run this command from the Pinme project root.',
]);
}
const configContent = fs.readFileSync(configPath, 'utf-8');
const projectNameMatch = configContent.match(/project_name\s*=\s*"([^"]+)"/);
return {
project_name: projectNameMatch?.[1] || '',
};
}
function getSqlFiles(): string[] {
const sqlDir = path.join(PROJECT_DIR, 'db');
if (!fs.existsSync(sqlDir)) {
throw createConfigError('SQL directory not found: `db/`.', [
'Create a `db/` directory and add at least one `.sql` migration file.',
]);
}
const files = fs.readdirSync(sqlDir)
.filter(f => f.endsWith('.sql'))
.sort();
if (files.length === 0) {
throw createConfigError('No `.sql` files were found in `db/`.', [
'Add one or more migration files before running `pinme update-db`.',
]);
}
return files.map(f => path.join(sqlDir, f));
}
// ============ 数据库更新 ============
async function updateDb(sqlFiles: string[], projectName: string) {
console.log(chalk.blue('Importing SQL files to database...'));
console.log(chalk.gray(`Project: ${projectName}`));
console.log(chalk.gray(`SQL files: ${sqlFiles.length}`));
const apiUrl = `${getPinmeApiUrl('/update_db')}?project_name=${encodeURIComponent(projectName)}`;
const headers = getAuthHeaders();
console.log(chalk.gray(`API URL: ${apiUrl}`));
try {
const FormData = (await import('formdata-node')).FormData;
const Blob = (await import('formdata-node')).Blob;
const formData = new FormData() as any;
// Add SQL files (can have multiple files with same field name)
let totalSize = 0;
for (const sqlFile of sqlFiles) {
const filename = path.basename(sqlFile);
const content = fs.readFileSync(sqlFile);
totalSize += content.length;
if (totalSize > 10 * 1024 * 1024) {
throw createConfigError('Total SQL payload exceeds the 10MB platform limit.', [
'Split migrations into smaller batches, then rerun `pinme update-db`.',
]);
}
formData.append('file', new Blob([content], {
type: 'application/sql',
}), filename);
console.log(chalk.gray(` Including: ${filename} (${content.length} bytes)`));
}
const response = await axios.post(apiUrl, formData, {
headers: { ...headers },
timeout: 120000,
});
console.log(chalk.gray(` Response: ${JSON.stringify(response.data)}`));
if (response.data.code === 200) {
console.log(chalk.green('SQL files imported successfully!'));
// Display results
const results = response.data.data.results;
for (const result of results) {
if (result.status === 'complete') {
console.log(chalk.green(` COMPLETE ${result.filename}: ${result.num_queries} queries, ${result.duration}ms`));
if (result.changes !== undefined) {
console.log(chalk.gray(` Changes: ${result.changes}, Read: ${result.rows_read}, Written: ${result.rows_written}`));
}
} else if (result.status === 'error') {
console.log(chalk.red(` ERROR ${result.filename}: ${result.error}`));
}
}
} else {
// Handle partial failure or other errors
throw createApiError('database update', { response: { data: response.data } }, [
`Project: ${projectName}`,
`Endpoint: ${apiUrl}`,
], [
'Inspect the SQL result list above to identify the failing migration file.',
]);
}
} catch (error: any) {
throw createApiError('database update', error, [
`Project: ${projectName}`,
`Endpoint: ${apiUrl}`,
], [
'Validate the SQL syntax and check whether any migration is re-applying an existing schema change.',
]);
}
}
// ============ 主函数 ============
/**
* Update database: import SQL files to project's D1 database
* API: POST /api/v4/update_db?project_name={name}
*/
export default async function updateDbCmd(options?: UpdateDbOptions): Promise<void> {
try {
// Check if user is logged in
const headers = getAuthHeaders();
if (!headers['authentication-tokens'] || !headers['token-address']) {
throw createConfigError('No valid local login session was found.', [
'Run `pinme login` and retry.',
]);
}
// Copy token to project directory for sub-commands
const projectDir = options?.projectName || options?.name ? path.join(PROJECT_DIR, options.projectName || options.name!) : PROJECT_DIR;
const tokenFileSrc = path.join(PROJECT_DIR, '.token.json');
const tokenFileDst = path.join(projectDir, '.token.json');
if (fs.existsSync(tokenFileSrc) && !fs.existsSync(tokenFileDst)) {
fs.copySync(tokenFileSrc, tokenFileDst);
}
console.log(chalk.blue('Importing SQL to database...\n'));
console.log(chalk.gray(`Project dir: ${PROJECT_DIR}`));
const config = loadConfig();
const projectName = config.project_name;
if (!projectName) {
throw createConfigError('`project_name` is missing in `pinme.toml`.', [
'Set `project_name = "your-project-name"` in `pinme.toml`.',
]);
}
console.log(chalk.gray(`Project: ${projectName}`));
// Get SQL files from db directory
const sqlFiles = getSqlFiles();
console.log(chalk.gray(`Found ${sqlFiles.length} SQL file(s) in db`));
await updateDb(sqlFiles, projectName);
console.log(chalk.green('\nDatabase update complete.'));
void tracker.trackEvent(
TRACK_EVENTS.projectUpdateDbSuccess,
TRACK_PAGES.deploy,
{
a: resolveTrackAction(TRACK_EVENTS.projectUpdateDbSuccess),
project_name: projectName,
sql_file_count: sqlFiles.length,
},
);
process.exit(0);
} catch (error: any) {
void tracker.trackEvent(
TRACK_EVENTS.projectUpdateDbFailed,
TRACK_PAGES.deploy,
{
a: resolveTrackAction(TRACK_EVENTS.projectUpdateDbFailed),
project_name: options?.projectName || options?.name,
reason: getTrackErrorReason(error),
},
);
printCliError(error, 'Database update failed.');
process.exit(1);
}
}
+155
View File
@@ -0,0 +1,155 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { execSync } from 'child_process';
import { getAuthHeaders } from './utils/webLogin';
import {
createCommandError,
createConfigError,
printCliError,
} from './utils/cliError';
import { APP_CONFIG } from './utils/config';
import { uploadPath } from './services/uploadService';
import { printHighlightedUrl } from './utils/urlDisplay';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
const PROJECT_DIR = process.cwd();
interface UpdateWebOptions {
projectName?: string;
name?: string;
}
// ============ 工具函数 ============
function loadConfig() {
const configPath = path.join(PROJECT_DIR, 'pinme.toml');
if (!fs.existsSync(configPath)) {
throw createConfigError('`pinme.toml` not found in the current directory.', [
'Run this command from the Pinme project root.',
]);
}
const configContent = fs.readFileSync(configPath, 'utf-8');
const projectNameMatch = configContent.match(/project_name\s*=\s*"([^"]+)"/);
return {
project_name: projectNameMatch?.[1] || '',
};
}
function getProjectManagementUrl(projectName: string): string {
return `${APP_CONFIG.projectPeviewUrl}${projectName}`;
}
// ============ 前端构建和部署 ============
function buildFrontend() {
console.log(chalk.blue('Building frontend...'));
try {
execSync('npm run build:frontend', {
cwd: PROJECT_DIR,
stdio: 'inherit',
});
console.log(chalk.green('Frontend built'));
} catch (error: any) {
throw createCommandError('frontend build', 'npm run build:frontend', error, [
'Fix the frontend build error shown above, then rerun `pinme update-web`.',
]);
}
}
async function deployFrontend(projectName: string): Promise<void> {
console.log(chalk.blue('Deploying frontend to IPFS...'));
try {
const headers = getAuthHeaders();
const uploadResult = await uploadPath(path.join(PROJECT_DIR, 'frontend', 'dist'), {
action: 'project_update_web',
projectName,
uid: headers['token-address'],
});
printHighlightedUrl('Frontend URL', uploadResult.publicUrl, 'primary');
printHighlightedUrl(
'Project Management URL',
getProjectManagementUrl(projectName),
'management',
);
} catch (error: any) {
throw createCommandError('frontend deploy', 'upload frontend/dist', error, [
'Make sure `frontend/dist` exists and the upload API is reachable.',
]);
}
}
// ============ 主函数 ============
/**
* Update web: build + upload frontend only (no worker, no SQL)
*/
export default async function updateWebCmd(options?: UpdateWebOptions): Promise<void> {
try {
// Check if user is logged in
const headers = getAuthHeaders();
if (!headers['authentication-tokens'] || !headers['token-address']) {
throw createConfigError('No valid local login session was found.', [
'Run `pinme login` and retry.',
]);
}
// Copy token to project directory for sub-commands
const projectDir = options?.projectName || options?.name ? path.join(PROJECT_DIR, options.projectName || options.name!) : PROJECT_DIR;
const tokenFileSrc = path.join(PROJECT_DIR, '.token.json');
const tokenFileDst = path.join(projectDir, '.token.json');
if (fs.existsSync(tokenFileSrc) && !fs.existsSync(tokenFileDst)) {
fs.copySync(tokenFileSrc, tokenFileDst);
}
console.log(chalk.blue('Updating web (frontend)...\n'));
console.log(chalk.gray(`Project dir: ${PROJECT_DIR}`));
const config = loadConfig();
const projectName = config.project_name;
if (!projectName) {
throw createConfigError('`project_name` is missing in `pinme.toml`.', [
'Set `project_name = "your-project-name"` in `pinme.toml`.',
]);
}
console.log(chalk.gray(`Project: ${projectName}`));
// Frontend: build + deploy
console.log(chalk.blue('\n--- Frontend Update ---'));
buildFrontend();
await deployFrontend(projectName);
console.log(chalk.green('\nWeb update complete.'));
void tracker.trackEvent(
TRACK_EVENTS.projectUpdateWebSuccess,
TRACK_PAGES.deploy,
{
a: resolveTrackAction(TRACK_EVENTS.projectUpdateWebSuccess),
project_name: projectName,
},
);
process.exit(0);
} catch (error: any) {
void tracker.trackEvent(
TRACK_EVENTS.projectUpdateWebFailed,
TRACK_PAGES.deploy,
{
a: resolveTrackAction(TRACK_EVENTS.projectUpdateWebFailed),
project_name: options?.projectName || options?.name,
reason: getTrackErrorReason(error),
},
);
printCliError(error, 'Web update failed.');
process.exit(1);
}
}
+267
View File
@@ -0,0 +1,267 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import axios from 'axios';
import { execSync } from 'child_process';
import { getAuthHeaders } from './utils/webLogin';
import {
createApiError,
createCommandError,
createConfigError,
printCliError,
} from './utils/cliError';
import { getPinmeApiUrl } from './utils/config';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
const PROJECT_DIR = process.cwd();
interface UpdateWorkerOptions {
projectName?: string;
name?: string;
}
// ============ 工具函数 ============
function loadConfig() {
const configPath = path.join(PROJECT_DIR, 'pinme.toml');
if (!fs.existsSync(configPath)) {
throw createConfigError('`pinme.toml` not found in the current directory.', [
'Run this command from the Pinme project root.',
]);
}
const configContent = fs.readFileSync(configPath, 'utf-8');
const projectNameMatch = configContent.match(/project_name\s*=\s*"([^"]+)"/);
return {
project_name: projectNameMatch?.[1] || '',
};
}
// ============ Worker 构建和部署 ============
function getMetadata() {
const metadataPath = path.join(PROJECT_DIR, 'backend', 'metadata.json');
if (!fs.existsSync(metadataPath)) {
throw createConfigError('`backend/metadata.json` not found.', [
'Create `backend/metadata.json` or restore it from the project template.',
]);
}
return fs.readJsonSync(metadataPath);
}
function buildWorker() {
console.log(chalk.blue('Building worker...'));
try {
execSync('npm run build:worker', {
cwd: PROJECT_DIR,
stdio: 'inherit',
});
console.log(chalk.green('Worker built'));
} catch (error: any) {
throw createCommandError('worker build', 'npm run build:worker', error, [
'Fix the build error shown above, then rerun `pinme update-worker`.',
]);
}
}
function getBuiltWorker(): { workerJsPath: string; modulePaths: string[] } {
const distWorkerDir = path.join(PROJECT_DIR, 'dist-worker');
if (!fs.existsSync(distWorkerDir)) {
throw createConfigError('Built worker output not found: `dist-worker/`.', [
'Make sure `npm run build:worker` completed successfully.',
]);
}
const workerJsPath = path.join(distWorkerDir, 'worker.js');
if (!fs.existsSync(workerJsPath)) {
throw createConfigError('Built worker entry file not found: `dist-worker/worker.js`.', [
'Check the worker build output and bundler config.',
]);
}
const modulePaths: string[] = [];
const files = fs.readdirSync(distWorkerDir);
for (const file of files) {
if (file.endsWith('.js') && file !== 'worker.js') {
modulePaths.push(path.join(distWorkerDir, file));
}
}
return { workerJsPath, modulePaths };
}
async function updateWorker(workerJsPath: string, modulePaths: string[], metadata: any, projectName: string) {
console.log(chalk.blue('Updating worker on platform...'));
console.log(chalk.gray(`Project: ${projectName}`));
console.log(chalk.gray(`workerJsPath: ${workerJsPath}`));
console.log(chalk.gray(`modulePaths: ${modulePaths}`));
console.log(chalk.gray(`metadata: ${metadata}`));
const apiUrl = `${getPinmeApiUrl('/update_worker')}?project_name=${encodeURIComponent(projectName)}`;
const headers = getAuthHeaders();
console.log(chalk.gray(`API URL: ${apiUrl}`));
try {
const FormData = (await import('formdata-node')).FormData;
const Blob = (await import('formdata-node')).Blob;
const formData = new FormData() as any;
// metadata
formData.append('metadata', new Blob([JSON.stringify(metadata)], {
type: 'application/json',
}), 'metadata.json');
// worker.js
const workerCode = fs.readFileSync(workerJsPath, 'utf-8');
formData.append('worker.js', new Blob([workerCode], {
type: 'application/javascript+module',
}), 'worker.js');
// Other modules
for (const modulePath of modulePaths) {
const filename = path.basename(modulePath);
const content = fs.readFileSync(modulePath, 'utf-8');
formData.append(filename, new Blob([content], {
type: 'application/javascript+module',
}), filename);
}
const response = await axios.put(apiUrl, formData, {
headers: { ...headers },
timeout: 120000,
});
console.log(chalk.gray(` Response: ${JSON.stringify(response.data)}`));
if (response.data) {
console.log(chalk.green('Worker updated'));
// Display worker deployment info
const data = response.data.data;
if (data.worker_id) {
console.log(chalk.gray(` Worker ID: ${data.worker_id}`));
}
if (data.deployment_id) {
console.log(chalk.gray(` Deployment ID: ${data.deployment_id}`));
}
if (data.entry_point) {
console.log(chalk.gray(` Entry Point: ${data.entry_point}`));
}
if (data.created_on) {
console.log(chalk.gray(` Created: ${data.created_on}`));
}
if (data.modified_on) {
console.log(chalk.gray(` Modified: ${data.modified_on}`));
}
if (data.startup_time_ms !== undefined) {
console.log(chalk.gray(` Startup Time: ${data.startup_time_ms}ms`));
}
if (data.has_modules !== undefined) {
console.log(chalk.gray(` Has Modules: ${data.has_modules}`));
}
if (data.domain) {
console.log(chalk.gray(` Domain: ${data.domain}`));
}
} else {
throw createApiError('worker update', { response: { data: response.data } }, [
`Project: ${projectName}`,
`Endpoint: ${apiUrl}`,
], [
'Verify the project exists and your account has permission to update it.',
]);
}
} catch (error: any) {
throw createApiError('worker update', error, [
`Project: ${projectName}`,
`Endpoint: ${apiUrl}`,
], [
'Check whether `backend/metadata.json` and the built worker bundle are valid.',
]);
}
}
// ============ 主函数 ============
/**
* Update worker: build + upload worker only (no SQL, no frontend)
* API: PUT /api/v4/update_worker?project_name={name}
*/
export default async function updateWorkerCmd(options?: UpdateWorkerOptions): Promise<void> {
try {
// Check if user is logged in
const headers = getAuthHeaders();
if (!headers['authentication-tokens'] || !headers['token-address']) {
throw createConfigError('No valid local login session was found.', [
'Run `pinme login` and retry.',
]);
}
// Copy token to project directory for sub-commands
const projectDir = options?.projectName || options?.name ? path.join(PROJECT_DIR, options.projectName || options.name!) : PROJECT_DIR;
const tokenFileSrc = path.join(PROJECT_DIR, '.token.json');
const tokenFileDst = path.join(projectDir, '.token.json');
if (fs.existsSync(tokenFileSrc) && !fs.existsSync(tokenFileDst)) {
fs.copySync(tokenFileSrc, tokenFileDst);
}
console.log(chalk.blue('Updating worker...\n'));
console.log(chalk.gray(`Project dir: ${PROJECT_DIR}`));
const config = loadConfig();
const projectName = config.project_name;
if (!projectName) {
throw createConfigError('`project_name` is missing in `pinme.toml`.', [
'Set `project_name = "your-project-name"` in `pinme.toml`.',
]);
}
console.log(chalk.gray(`Project: ${projectName}`));
// Backend: build + update
console.log(chalk.blue('\n--- Worker Update ---'));
buildWorker();
const metadata = getMetadata();
const { workerJsPath, modulePaths } = getBuiltWorker();
console.log(chalk.gray(`Worker JS: ${workerJsPath}`));
console.log(chalk.gray(`Module paths: ${JSON.stringify(modulePaths)}`));
// Note: SQL files are ignored for update_worker
console.log(chalk.gray(`SQL files: ignored (not processed for update_worker)`));
await updateWorker(workerJsPath, modulePaths, metadata, projectName);
console.log(chalk.green('\nWorker update complete.'));
void tracker.trackEvent(
TRACK_EVENTS.projectUpdateWorkerSuccess,
TRACK_PAGES.deploy,
{
a: resolveTrackAction(TRACK_EVENTS.projectUpdateWorkerSuccess),
project_name: projectName,
module_count: modulePaths.length,
},
);
process.exit(0);
} catch (error: any) {
void tracker.trackEvent(
TRACK_EVENTS.projectUpdateWorkerFailed,
TRACK_PAGES.deploy,
{
a: resolveTrackAction(TRACK_EVENTS.projectUpdateWorkerFailed),
project_name: options?.projectName || options?.name,
reason: getTrackErrorReason(error),
},
);
printCliError(error, 'Worker update failed.');
process.exit(1);
}
}
+560
View File
@@ -0,0 +1,560 @@
import path from 'path';
import chalk from 'chalk';
import inquirer from 'inquirer';
import figlet from 'figlet';
import fs from 'fs';
import {
checkDomainAvailable,
bindPinmeDomain,
bindDnsDomainV4,
getWalletBalance,
} from './utils/pinmeApi';
import { getAuthConfig } from './utils/webLogin';
import { APP_CONFIG, getWalletRechargeUrl } from './utils/config';
import {
isDnsDomain,
normalizeDomain,
validateDnsDomain,
} from './utils/domainValidator';
import { printCliError, printRechargeUrl } from './utils/cliError';
import { resolveUploadUrls, uploadPath } from './services/uploadService';
import { printHighlightedUrl } from './utils/urlDisplay';
import tracker, {
getPathKind,
getTrackErrorReason,
} from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
import { checkNodeVersion } from './utils/checkNodeVersion';
checkNodeVersion();
// create a synchronous path check function
function checkPathSync(inputPath: string): string | null {
try {
// convert to absolute path
const absolutePath = path.resolve(inputPath);
// check if the path exists
if (fs.existsSync(absolutePath)) {
return absolutePath;
}
return null;
} catch (error: any) {
console.error(chalk.red(`error checking path: ${error.message}`));
return null;
}
}
interface UploadOptions {
[key: string]: any;
}
async function printUploadUrls(
result: {
contentHash: string;
shortUrl?: string;
pinmeUrl?: string;
dnsUrl?: string;
},
projectName?: string,
): Promise<void> {
const { publicUrl, managementUrl } = await resolveUploadUrls(
result.contentHash,
{
dnsUrl: result.dnsUrl,
pinmeUrl: result.pinmeUrl,
shortUrl: result.shortUrl,
},
projectName,
);
printHighlightedUrl('URL', publicUrl, 'primary');
printHighlightedUrl('Management URL', managementUrl, 'management');
}
function readProjectNameFromConfig(configPath: string): string | undefined {
try {
const config = fs.readFileSync(configPath, 'utf-8');
const match = config.match(/project_name\s*=\s*"([^"]+)"/);
return match?.[1]?.trim() || undefined;
} catch {
return undefined;
}
}
function findPinmeConfig(startPath: string): string | undefined {
try {
let current = fs.statSync(startPath).isDirectory()
? startPath
: path.dirname(startPath);
while (true) {
const configPath = path.join(current, 'pinme.toml');
if (fs.existsSync(configPath)) {
return configPath;
}
const parent = path.dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
} catch {
return undefined;
}
}
function shouldInferProjectNameFromUploadPath(targetPath: string): boolean {
try {
return (
fs.statSync(targetPath).isDirectory() &&
path.basename(targetPath) === 'dist'
);
} catch {
return false;
}
}
function resolveUploadProjectName(targetPath: string): string | undefined {
if (APP_CONFIG.pinmeProjectName) {
return APP_CONFIG.pinmeProjectName;
}
if (!shouldInferProjectNameFromUploadPath(targetPath)) {
return undefined;
}
const configPaths = [
findPinmeConfig(targetPath),
findPinmeConfig(process.cwd()),
].filter(
(value, index, values): value is string =>
Boolean(value) && values.indexOf(value) === index,
);
for (const configPath of configPaths) {
const projectName = readProjectNameFromConfig(configPath);
if (projectName) {
return projectName;
}
}
return undefined;
}
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;
}
function getDnsFromArgs(): boolean {
const args = process.argv.slice(2);
return args.includes('--dns') || args.includes('-D');
}
async function checkWalletBalanceStatus(authConfig: {
address: string;
token: string;
}): Promise<boolean> {
console.log(chalk.blue('Checking wallet balance...'));
try {
const balanceResult = await getWalletBalance(
authConfig.address,
authConfig.token,
);
const balance = Number(balanceResult.data?.wallet_balance_usd ?? 0);
if (!Number.isFinite(balance) || balance <= 0) {
return false;
}
console.log(
chalk.green(`Wallet balance available: $${balance.toFixed(2)}`),
);
return true;
} catch (e: any) {
if (e.message === 'Token expired' || e?.name === 'CliError') {
throw e;
}
console.log(chalk.yellow('Failed to check wallet balance, continuing...'));
return true;
}
}
async function bindDomain(
domain: string,
contentHash: string,
isDns: boolean,
authConfig: { address: string; token: string },
): Promise<boolean> {
const displayDomain = normalizeDomain(domain);
const domainType = isDns ? 'dns' : 'pinme_subdomain';
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) {
void tracker.trackEvent(TRACK_EVENTS.domainBindFailed, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindFailed),
domain_type: domainType,
domain_name: displayDomain,
bind_source: 'upload',
reason: dnsResult.msg || 'dns_bind_failed',
});
console.log(chalk.red(`DNS binding failed: ${dnsResult.msg}`));
return false;
}
void tracker.trackEvent(TRACK_EVENTS.domainBindSuccess, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindSuccess),
domain_type: domainType,
domain_name: displayDomain,
bind_source: 'upload',
});
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) {
void tracker.trackEvent(TRACK_EVENTS.domainBindFailed, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindFailed),
domain_type: domainType,
domain_name: displayDomain,
bind_source: 'upload',
reason: 'pinme_bind_failed',
});
console.log(chalk.red('Binding failed. Please try again later.'));
return false;
}
void tracker.trackEvent(TRACK_EVENTS.domainBindSuccess, TRACK_PAGES.domain, {
a: resolveTrackAction(TRACK_EVENTS.domainBindSuccess),
domain_type: domainType,
domain_name: displayDomain,
bind_source: 'upload',
});
console.log(chalk.green(`Bind success: ${displayDomain}`));
const rootDomain = await (await import('./utils/pinmeApi')).getRootDomain();
console.log(chalk.white(`Visit: https://${displayDomain}.${rootDomain}`));
}
return true;
}
export default async (options?: UploadOptions): Promise<void> => {
try {
console.log(
figlet.textSync('PINME', {
font: 'Standard',
horizontalLayout: 'default',
verticalLayout: 'default',
width: 180,
whitespaceBreak: true,
}),
);
const authConfig = getAuthConfig();
if (!authConfig) {
console.log(chalk.red('Please login first. Run: pinme login'));
return;
}
// Check if domain/dns options are provided
const domainArg = getDomainFromArgs();
const dnsArg = getDnsFromArgs();
// if the parameter is passed, upload directly, pinme upload /path/to/dir
const argPath = process.argv[3];
if (argPath && !argPath.startsWith('-')) {
// use the synchronous path check function
const absolutePath = checkPathSync(argPath);
if (!absolutePath) {
console.log(chalk.red(`path ${argPath} does not exist`));
return;
}
const pathKind = getPathKind(absolutePath);
const projectName = resolveUploadProjectName(absolutePath);
if (projectName) {
console.log(chalk.gray(`Project: ${projectName}`));
}
// 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;
}
}
// Domain binding now uses wallet balance instead of VIP.
if (domainArg) {
try {
const hasWalletBalance = await checkWalletBalanceStatus(authConfig);
if (!hasWalletBalance) {
console.log(
chalk.red(
'Insufficient wallet balance. Please recharge your wallet first.',
),
);
printRechargeUrl(getWalletRechargeUrl());
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...`));
let result;
try {
result = await uploadPath(absolutePath, {
action: 'upload',
projectName,
uid: authConfig?.address,
});
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.uploadFailed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadFailed),
path_kind: pathKind,
has_domain: Boolean(domainArg),
reason: getTrackErrorReason(error),
});
printCliError(error, 'Upload failed.');
process.exit(1);
}
if (!result) {
void tracker.trackEvent(TRACK_EVENTS.uploadFailed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadFailed),
path_kind: pathKind,
has_domain: Boolean(domainArg),
reason: 'no_result_returned',
});
console.error(chalk.red('Upload failed: no result returned'));
process.exit(1);
}
void tracker.trackEvent(TRACK_EVENTS.uploadSuccess, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadSuccess),
path_kind: pathKind,
has_domain: Boolean(domainArg),
project_name: projectName,
});
console.log(
chalk.cyan(figlet.textSync('Successful', { horizontalLayout: 'full' })),
);
await printUploadUrls(result, projectName);
// optional: bind domain after upload
if (domainArg) {
console.log(
chalk.blue(
`Binding domain: ${displayDomain} with CID: ${result.contentHash}`,
),
);
try {
await bindDomain(domainArg, result.contentHash, isDns, authConfig);
} catch (e: any) {
if (e.message === 'Token expired') {
process.exit(1);
}
throw e;
}
}
console.log(chalk.green('\n🎉 upload successful, program exit'));
process.exit(0);
}
// No path argument provided, use interactive mode
const answer = await inquirer.prompt([
{
type: 'input',
name: 'path',
message: 'path to upload: ',
},
]);
if (answer.path) {
// use the synchronous path check function
const absolutePath = checkPathSync(answer.path);
if (!absolutePath) {
console.log(chalk.red(`path ${answer.path} does not exist`));
return;
}
const pathKind = getPathKind(absolutePath);
const projectName = resolveUploadProjectName(absolutePath);
if (projectName) {
console.log(chalk.gray(`Project: ${projectName}`));
}
// 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;
}
}
// Domain binding now uses wallet balance instead of VIP.
if (domainArg) {
try {
const hasWalletBalance = await checkWalletBalanceStatus(authConfig);
if (!hasWalletBalance) {
console.log(
chalk.red(
'Insufficient wallet balance. Please recharge your wallet first.',
),
);
printRechargeUrl(getWalletRechargeUrl());
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...`));
let result;
try {
result = await uploadPath(absolutePath, {
action: 'upload',
projectName,
uid: authConfig?.address,
});
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.uploadFailed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadFailed),
path_kind: pathKind,
has_domain: Boolean(domainArg),
reason: getTrackErrorReason(error),
});
printCliError(error, 'Upload failed.');
process.exit(1);
}
if (!result) {
void tracker.trackEvent(TRACK_EVENTS.uploadFailed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadFailed),
path_kind: pathKind,
has_domain: Boolean(domainArg),
reason: 'no_result_returned',
});
console.error(chalk.red('Upload failed: no result returned'));
process.exit(1);
}
void tracker.trackEvent(TRACK_EVENTS.uploadSuccess, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadSuccess),
path_kind: pathKind,
has_domain: Boolean(domainArg),
project_name: projectName,
});
console.log(
chalk.cyan(figlet.textSync('Successful', { horizontalLayout: 'full' })),
);
await printUploadUrls(result, projectName);
if (domainArg) {
console.log(
chalk.blue(
`Binding domain: ${displayDomain} with CID: ${result.contentHash}`,
),
);
try {
await bindDomain(domainArg, result.contentHash, isDns, authConfig);
} catch (e: any) {
if (e.message === 'Token expired') {
process.exit(1);
}
throw e;
}
}
console.log(chalk.green('\n🎉 upload successful, program exit'));
process.exit(0);
}
} catch (error: any) {
printCliError(error, 'Upload failed.');
}
};
+113
View File
@@ -0,0 +1,113 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { createApiError } from './cliError';
import { getAuthHeaders } from './webLogin';
import { APP_CONFIG } from './config';
interface CreateApiClientOptions {
baseURL?: string;
includeAuth?: boolean;
timeout?: number;
headers?: Record<string, string>;
}
function safeGetAuthHeaders(): Record<string, string> {
try {
return getAuthHeaders();
} catch (error) {
return {};
}
}
function hasBusinessCode(data: any): boolean {
return Boolean(data) && typeof data === 'object' && 'code' in data;
}
function isSuccessfulBusinessCode(code: unknown): boolean {
return String(code) === '200';
}
function getRequestDescriptor(config?: AxiosRequestConfig): string | undefined {
if (!config?.url) {
return undefined;
}
const method = (config.method || 'GET').toUpperCase();
return `${method} ${config.url}`;
}
function buildErrorContext(config?: AxiosRequestConfig): string[] {
const descriptor = getRequestDescriptor(config);
return descriptor ? [`Request: ${descriptor}`] : [];
}
function normalizeBusinessError(response: AxiosResponse): never {
throw createApiError(
'API request',
{ response, config: response.config },
buildErrorContext(response.config),
);
}
export function createApiClient(
options: CreateApiClientOptions = {},
): AxiosInstance {
const {
baseURL = APP_CONFIG.pinmeApiBase,
includeAuth = true,
timeout = 20000,
headers = {},
} = options;
const client = axios.create({
baseURL,
timeout,
headers: {
...(includeAuth ? safeGetAuthHeaders() : {}),
Accept: '*/*',
'Content-Type': 'application/json',
'User-Agent': 'Pinme-CLI',
Connection: 'keep-alive',
...headers,
},
});
client.interceptors.response.use(
(response) => {
if (
hasBusinessCode(response.data)
&& !isSuccessfulBusinessCode(response.data.code)
) {
normalizeBusinessError(response);
}
return response;
},
(error) => Promise.reject(
createApiError(
'API request',
error,
buildErrorContext(error?.config),
),
),
);
return client;
}
export function createPinmeApiClient(
options: Omit<CreateApiClientOptions, 'baseURL'> = {},
): AxiosInstance {
return createApiClient({
...options,
baseURL: APP_CONFIG.pinmeApiBase,
});
}
export function createCarApiClient(
options: Omit<CreateApiClientOptions, 'baseURL'> = {},
): AxiosInstance {
return createApiClient({
...options,
baseURL: APP_CONFIG.carApiBase,
});
}
+71
View File
@@ -0,0 +1,71 @@
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
const CONFIG_DIR = path.join(os.homedir(), '.pinme');
const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
export interface AuthConfig {
address: string;
token: string;
}
function ensureConfigDir(): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
}
export function parseCombinedToken(combined: string): AuthConfig {
// combined format: "<address>-<jwt>"
// Split only at the first '-' to preserve '-' inside JWT if any.
const firstDash = combined.indexOf('-');
if (firstDash <= 0 || firstDash === combined.length - 1) {
throw new Error('Invalid token format. Expected "<address>-<jwt>".');
}
const address = combined.slice(0, firstDash).trim();
const token = combined.slice(firstDash + 1).trim();
if (!address || !token) {
throw new Error('Invalid token content. Address or token is empty.');
}
return { address, token };
}
export function setAuthToken(combined: string): AuthConfig {
ensureConfigDir();
const auth = parseCombinedToken(combined);
fs.writeJsonSync(AUTH_FILE, auth, { spaces: 2 });
return auth;
}
export function clearAuthToken(): void {
try {
if (fs.existsSync(AUTH_FILE)) {
fs.removeSync(AUTH_FILE);
}
} catch (error) {
console.error(`Failed to clear auth token: ${error}`);
}
}
export function getAuthConfig(): AuthConfig | null {
try {
if (!fs.existsSync(AUTH_FILE)) return null;
const data = fs.readJsonSync(AUTH_FILE) as AuthConfig;
if (!data?.address || !data?.token) return null;
return data;
} catch {
return null;
}
}
export function getAuthHeaders(): Record<string, string> {
const conf = getAuthConfig();
if (!conf) {
throw new Error('Auth not set. Run: pinme set-appkey <AppKey>');
}
return {
'token-address': conf.address,
'authentication-tokens': conf.token,
};
}
+62
View File
@@ -0,0 +1,62 @@
import chalk from 'chalk';
const MIN_NODE_VERSION = '16.13.0';
interface Version {
major: number;
minor: number;
patch: number;
}
function parseVersion(version: string): Version {
const parts = version.replace(/^v/, '').split('.').map(Number);
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0,
};
}
function compareVersions(version1: Version, version2: Version): number {
if (version1.major !== version2.major) {
return version1.major - version2.major;
}
if (version1.minor !== version2.minor) {
return version1.minor - version2.minor;
}
return version1.patch - version2.patch;
}
export function checkNodeVersion(): void {
const currentVersion = process.version;
const current = parseVersion(currentVersion);
const minimum = parseVersion(MIN_NODE_VERSION);
if (compareVersions(current, minimum) < 0) {
console.error(chalk.red('❌ Node.js version requirement not met'));
console.error(chalk.yellow(`Current version: ${currentVersion}`));
console.error(chalk.yellow(`Required version: >= ${MIN_NODE_VERSION}`));
console.error('');
console.error(chalk.cyan('Please upgrade Node.js to version 16.13.0 or higher.'));
console.error(chalk.cyan('Download from: https://nodejs.org/'));
console.error('');
process.exit(1);
}
}
export function checkNodeVersionWithMessage(): void {
const currentVersion = process.version;
const current = parseVersion(currentVersion);
const minimum = parseVersion(MIN_NODE_VERSION);
if (compareVersions(current, minimum) < 0) {
console.log(chalk.red('❌ Node.js version requirement not met'));
console.log(chalk.yellow(`Current version: ${currentVersion}`));
console.log(chalk.yellow(`Required version: >= ${MIN_NODE_VERSION}`));
console.log('');
console.log(chalk.cyan('Please upgrade Node.js to version 16.13.0 or higher.'));
console.log(chalk.cyan('Download from: https://nodejs.org/'));
console.log('');
process.exit(1);
}
}
+54
View File
@@ -0,0 +1,54 @@
import fs from 'fs';
import path from 'path';
import Inquirer from "inquirer";
// Check if the path exists and return the absolute path
function checkPath(inputPath: string): string | null {
try {
// Convert to absolute path
const absolutePath = path.resolve(inputPath);
// Check if the path exists
if (fs.existsSync(absolutePath)) {
return absolutePath;
}
return null;
} catch (error: any) {
console.error(`Error checking path: ${error.message}`);
return null;
}
}
export default async function(projectName: string): Promise<boolean> {
// Get the current working directory
const cwd = process.cwd();
// Get the project directory
const targetDirectory = path.join(cwd, projectName);
// Check if the directory exists
if (fs.existsSync(targetDirectory)) {
let { isOverwrite } = await Inquirer.prompt([
{
name: "isOverwrite", // Corresponding to the return value
type: "list", // List type
message: "Target directory exists, Please choose an action",
choices: [
{ name: "Overwrite", value: true },
{ name: "Cancel", value: false },
],
},
]);
// Choose Cancel
if (!isOverwrite) {
console.log("\n Canceled \n");
return false;
} else {
// Choose Overwrite, delete the existing directory
console.log("Removing folder...");
await fs.promises.rm(targetDirectory, { recursive: true, force: true });
console.log("Folder removed!");
return true
}
} else {
return true;
}
};
+276
View File
@@ -0,0 +1,276 @@
import chalk from 'chalk';
import { getWalletRechargeUrl } from './config';
interface CliErrorOptions {
summary: string;
stage?: string;
details?: string[];
suggestions?: string[];
cause?: unknown;
}
export class CliError extends Error {
stage?: string;
details: string[];
suggestions: string[];
cause?: unknown;
constructor(options: CliErrorOptions) {
super(options.summary);
this.name = 'CliError';
this.stage = options.stage;
this.details = options.details || [];
this.suggestions = options.suggestions || [];
this.cause = options.cause;
}
}
function stringifyValue(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value);
} catch (error) {
return String(value);
}
}
function getApiMessage(data: any): string | undefined {
if (typeof data === 'string') {
return data;
}
return data?.msg
|| data?.message
|| data?.data?.msg
|| data?.data?.message
|| data?.data?.error
|| data?.errors?.[0]?.message
|| data?.error;
}
function getApiDetailMessage(data: any): string | undefined {
if (typeof data === 'string') {
return data;
}
return data?.data?.error
|| data?.data?.msg
|| data?.data?.message
|| data?.errors?.[0]?.message
|| data?.error;
}
function getBusinessCode(data: any): string | undefined {
if (data?.code === undefined || data?.code === null) {
return undefined;
}
return String(data.code);
}
function getBusinessMessage(data: any): string | undefined {
if (!data?.msg) {
return undefined;
}
return String(data.msg);
}
function dedupeSuggestions(suggestions: string[]): string[] {
return Array.from(new Set(suggestions.filter(Boolean)));
}
function getRechargeUrl(detail: string): string | null {
const prefix = 'Recharge URL: ';
if (!detail.startsWith(prefix)) {
return null;
}
return detail.slice(prefix.length).trim() || null;
}
export function printRechargeUrl(url: string, useErrorStream: boolean = false): void {
const output = useErrorStream ? console.error : console.log;
output('');
output(chalk.yellowBright.bold('Recharge URL:'));
output(chalk.blueBright.bold.underline(url));
}
function isInsufficientBalanceError(
businessCode: string | undefined,
...messages: Array<string | undefined>
): boolean {
if (businessCode === '40001') {
return true;
}
const combined = messages
.filter(Boolean)
.join(' ')
.toLowerCase();
return combined.includes('insufficient balance')
|| combined.includes('insufficient wallet balance');
}
export function createConfigError(summary: string, suggestions: string[] = []): CliError {
return new CliError({
summary,
stage: 'configuration',
suggestions,
});
}
export function createCommandError(stage: string, command: string, error: any, suggestions: string[] = []): CliError {
const exitCode = error?.status ?? error?.code;
const signal = error?.signal;
const detailLines = [`Command: ${command}`];
if (exitCode !== undefined) {
detailLines.push(`Exit code: ${exitCode}`);
}
if (signal) {
detailLines.push(`Signal: ${signal}`);
}
if (error?.message) {
detailLines.push(`Reason: ${error.message}`);
}
return new CliError({
summary: `${stage} failed.`,
stage,
details: detailLines,
suggestions,
cause: error,
});
}
export function createApiError(stage: string, error: any, context: string[] = [], suggestions: string[] = []): CliError {
const status = error?.response?.status;
const responseData = error?.response?.data;
const errorCode = error?.code;
const rawMessage = error?.message;
const apiMessage = getApiMessage(responseData);
const apiDetailMessage = getApiDetailMessage(responseData);
const businessCode = getBusinessCode(responseData);
const businessMessage = getBusinessMessage(responseData);
const hasInsufficientBalanceError = isInsufficientBalanceError(
businessCode,
apiMessage,
apiDetailMessage,
businessMessage,
rawMessage,
);
const summary = apiMessage
|| businessMessage
|| apiDetailMessage
|| rawMessage
|| `${stage} failed.`;
const detailLines = [...context];
const hasBusinessError = Boolean(businessCode);
if (businessCode) {
detailLines.push(`Business code: ${businessCode}`);
}
if (status && !hasBusinessError) {
detailLines.push(`HTTP status: ${status}`);
}
if (businessMessage && businessMessage !== summary) {
detailLines.push(`Business message: ${businessMessage}`);
}
if (apiDetailMessage && apiDetailMessage !== summary && apiDetailMessage !== businessMessage) {
detailLines.push(`Error detail: ${apiDetailMessage}`);
}
if (apiMessage && apiMessage !== summary && apiMessage !== apiDetailMessage) {
detailLines.push(`Error message: ${apiMessage}`);
}
if (hasInsufficientBalanceError) {
detailLines.push(`Recharge URL: ${getWalletRechargeUrl()}`);
}
if (errorCode && errorCode !== 'ERR_BAD_REQUEST' && !responseData) {
detailLines.push(`Error code: ${errorCode}`);
}
const isGenericAxiosStatusMessage = typeof rawMessage === 'string'
&& /^Request failed with status code \d{3}$/.test(rawMessage);
if (rawMessage && rawMessage !== summary && !(responseData && isGenericAxiosStatusMessage)) {
detailLines.push(`Reason: ${rawMessage}`);
}
return new CliError({
summary,
stage,
details: detailLines,
suggestions: dedupeSuggestions(suggestions),
cause: error,
});
}
export function normalizeCliError(error: unknown, fallbackSummary: string, suggestions: string[] = []): CliError {
if (error instanceof CliError) {
return error;
}
if (typeof error === 'object' && error !== null) {
const maybeApiError = error as Record<string, any>;
if (maybeApiError.response || maybeApiError.config) {
return createApiError('API request', maybeApiError, [], suggestions);
}
}
if (error instanceof Error) {
return new CliError({
summary: error.message || fallbackSummary,
suggestions: dedupeSuggestions(suggestions),
cause: error,
});
}
return new CliError({
summary: fallbackSummary,
details: [`Raw error: ${stringifyValue(error)}`],
suggestions: dedupeSuggestions(suggestions),
cause: error,
});
}
export function printCliError(error: unknown, fallbackSummary: string): void {
const cliError = normalizeCliError(error, fallbackSummary);
console.error(chalk.red(`\nError: ${cliError.message}`));
if (cliError.stage) {
console.error(chalk.gray(`Stage: ${cliError.stage}`));
}
for (const detail of cliError.details) {
const rechargeUrl = getRechargeUrl(detail);
if (rechargeUrl) {
printRechargeUrl(rechargeUrl, true);
continue;
}
console.error(chalk.gray(detail));
}
if (cliError.suggestions.length > 0) {
console.error(chalk.yellow('\nNext steps:'));
for (const suggestion of cliError.suggestions) {
console.error(chalk.yellow(`- ${suggestion}`));
}
}
}
+78
View File
@@ -0,0 +1,78 @@
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
}
function readNumberEnv(name: string, fallback: number): number {
const rawValue = process.env[name];
if (!rawValue) {
return fallback;
}
const parsed = Number.parseInt(rawValue, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
const DEFAULT_PINME_WEB_URL = 'http://localhost:5173';
const PROD_WALLET_RECHARGE_URL = 'https://pinme.eth.limo/#/profile?tab=wallet';
const TEST_WALLET_RECHARGE_URL =
'https://test-pinme.pinit.eth.limo/#/profile?tab=wallet';
export const APP_CONFIG = {
pinmeApiBase: trimTrailingSlash(process.env.PINME_API_BASE || ''),
ipfsApiUrl: trimTrailingSlash(process.env.IPFS_API_URL || ''),
carApiBase: trimTrailingSlash(
process.env.CAR_API_BASE ||
process.env.IPFS_API_URL ||
'http://ipfs-proxy.opena.chat/api/v3',
),
pinmeWebUrl: trimTrailingSlash(
process.env.PINME_WEB_URL || DEFAULT_PINME_WEB_URL,
),
pinmeCheckDomainPath: process.env.PINME_CHECK_DOMAIN_PATH || '/check_domain',
ipfsPreviewUrl: process.env.IPFS_PREVIEW_URL || '',
projectPeviewUrl: process.env.PROJECT_PREVIEW_URL || '',
secretKey: process.env.SECRET_KEY,
pinmeProjectName: process.env.PINME_PROJECT_NAME?.trim(),
upload: {
maxRetries: readNumberEnv('MAX_RETRIES', 2),
retryDelayMs: readNumberEnv('RETRY_DELAY_MS', 1000),
timeoutMs: readNumberEnv('TIMEOUT_MS', 600000),
maxPollTimeMs: readNumberEnv('MAX_POLL_TIME_MINUTES', 5) * 60 * 1000,
pollIntervalMs: readNumberEnv('POLL_INTERVAL_SECONDS', 2) * 1000,
pollTimeoutMs: readNumberEnv('POLL_TIMEOUT_SECONDS', 10) * 1000,
},
};
export function getPinmeApiUrl(pathname: string): string {
const normalizedPath = pathname.startsWith('/') ? pathname : `/${pathname}`;
return `${APP_CONFIG.pinmeApiBase}${normalizedPath}`;
}
export function getIpfsApiUrl(pathname: string): string {
const normalizedPath = pathname.startsWith('/') ? pathname : `/${pathname}`;
return `${APP_CONFIG.ipfsApiUrl}${normalizedPath}`;
}
export function getCarApiUrl(pathname: string): string {
const normalizedPath = pathname.startsWith('/') ? pathname : `/${pathname}`;
return `${APP_CONFIG.carApiBase}${normalizedPath}`;
}
function includesAny(value: string, markers: string[]): boolean {
return markers.some((marker) => value.includes(marker));
}
export function getWalletRechargeUrl(): string {
const candidates = [APP_CONFIG.ipfsApiUrl].filter(Boolean);
if (
candidates.some((candidate) =>
includesAny(candidate, ['test-pinme', 'localhost:5173', 'benny1996.win']),
)
) {
return TEST_WALLET_RECHARGE_URL;
}
return PROD_WALLET_RECHARGE_URL;
}
+47
View File
@@ -0,0 +1,47 @@
import CryptoJS from "crypto-js";
export interface DecryptedHash {
contentHash: string;
uid?: string; // address (if logged in) or deviceId (if not logged in)
version: number; // 1 if old version (no '-' separator), 2 if new version (contentHash-uid)
}
export const decryptHash = (encryptedHash: string | undefined, key: string): DecryptedHash | null => {
try {
if (!encryptedHash) {
return null;
}
let base64 = encryptedHash.replace(/-/g, "+").replace(/_/g, "/");
while (base64.length % 4) {
base64 += "=";
}
const decrypted = CryptoJS.RC4.decrypt(base64, key);
const decryptedStr = decrypted.toString(CryptoJS.enc.Utf8);
// Check if it contains '-' separator (new version: contentHash-uid)
const dashIndex = decryptedStr.indexOf('-');
if (dashIndex > 0 && dashIndex < decryptedStr.length - 1) {
// New version: split into contentHash and uid (address or deviceId)
const contentHash = decryptedStr.slice(0, dashIndex);
const uid = decryptedStr.slice(dashIndex + 1);
return {
contentHash,
uid,
version: 2,
};
} else {
// Legacy version: only contentHash, no separator
return {
contentHash: decryptedStr,
version: 1,
};
}
} catch (error: any) {
console.error(`Decryption error: ${error.message}`);
// Return as legacy format if decryption fails
return encryptedHash ? {
contentHash: encryptedHash,
version: 1,
} : null;
}
};
+63
View File
@@ -0,0 +1,63 @@
export interface DomainValidationResult {
valid: boolean;
message?: string;
}
export function normalizeDomain(domain: string): string {
return domain.replace(/^https?:\/\//, '').replace(/\/$/, '');
}
export function isDnsDomain(domain: string): boolean {
return normalizeDomain(domain).includes('.');
}
export function validateDnsDomain(domain: string): DomainValidationResult {
const cleanDomain = normalizeDomain(domain);
const domainRegex =
/^[a-zA-Z0-9][a-zA-Z0-9-]*(\.[a-zA-Z0-9][a-zA-Z0-9-]*)*\.[a-zA-Z]{2,}$/;
const parts = cleanDomain.split('.');
if (parts.length < 2) {
return {
valid: false,
message:
'Invalid domain format. Please enter a complete domain (e.g., example.com)',
};
}
for (const part of parts) {
if (part.length === 0) {
return {
valid: false,
message: 'Invalid domain format. Consecutive dots are not allowed',
};
}
if (part.length > 63) {
return {
valid: false,
message:
'Invalid domain format. Each label must be 63 characters or less',
};
}
if (!/^[a-zA-Z0-9-]+$/.test(part)) {
return {
valid: false,
message:
'Invalid domain format. Domains can only contain letters, numbers, and hyphens',
};
}
if (/^-|-$/.test(part)) {
return {
valid: false,
message:
'Invalid domain format. Labels cannot start or end with hyphens',
};
}
}
if (!domainRegex.test(cleanDomain)) {
return { valid: false, message: 'Invalid domain format' };
}
return { valid: true };
}
+105
View File
@@ -0,0 +1,105 @@
import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream';
import { promisify } from 'util';
const pipelineAsync = promisify(pipeline);
export interface DownloadFileWithRetriesOptions {
attempts?: number;
retryDelayMs?: number;
minBytes?: number;
timeoutMs?: number;
request?: (
url: string,
options: { timeoutMs: number; headers: Record<string, string> },
) => Promise<{ data: NodeJS.ReadableStream }>;
onAttempt?: (attempt: number, attempts: number) => void;
onAttemptFailure?: (attempt: number, error: unknown) => void;
}
export interface DownloadFileResult {
attempts: number;
bytes: number;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function getDownloadErrorMessage(error: any): string {
const status = error?.response?.status;
const statusText = error?.response?.statusText;
if (status) {
return `HTTP ${status}${statusText ? ` ${statusText}` : ''}`;
}
if (error?.code && error?.message) {
return `${error.code}: ${error.message}`;
}
return error?.message || String(error);
}
async function requestDownload(
url: string,
options: { timeoutMs: number; headers: Record<string, string> },
): Promise<{ data: NodeJS.ReadableStream }> {
return axios.get(url, {
responseType: 'stream',
timeout: options.timeoutMs,
headers: options.headers,
});
}
export async function downloadFileWithRetries(
url: string,
destinationPath: string,
options: DownloadFileWithRetriesOptions = {},
): Promise<DownloadFileResult> {
const attempts = options.attempts ?? 3;
const retryDelayMs = options.retryDelayMs ?? 2000;
const minBytes = options.minBytes ?? 1;
const timeoutMs = options.timeoutMs ?? 120000;
const request = options.request ?? requestDownload;
const headers = {
'User-Agent': 'pinme-cli',
};
let lastError: unknown;
fs.ensureDirSync(path.dirname(destinationPath));
fs.removeSync(destinationPath);
for (let attempt = 1; attempt <= attempts; attempt++) {
const tempPath = `${destinationPath}.download-${process.pid}-${Date.now()}-${attempt}.tmp`;
try {
options.onAttempt?.(attempt, attempts);
const response = await request(url, { timeoutMs, headers });
await pipelineAsync(response.data, createWriteStream(tempPath));
const bytes = fs.statSync(tempPath).size;
if (bytes < minBytes) {
throw new Error(`Downloaded file is too small (${bytes} bytes; expected at least ${minBytes} bytes).`);
}
fs.moveSync(tempPath, destinationPath, { overwrite: true });
return { attempts: attempt, bytes };
} catch (error) {
lastError = error;
fs.removeSync(tempPath);
options.onAttemptFailure?.(attempt, error);
if (attempt < attempts) {
await sleep(retryDelayMs);
}
}
}
throw new Error(`Failed to download ${url} after ${attempts} attempts: ${getDownloadErrorMessage(lastError)}`);
}
+30
View File
@@ -0,0 +1,30 @@
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
import { getAuthConfig } from './webLogin';
export function getDeviceId(): string {
const configDir = path.join(os.homedir(), '.pinme');
const configFile = path.join(configDir, 'device-id');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
if (fs.existsSync(configFile)) {
return fs.readFileSync(configFile, 'utf8').trim();
}
const deviceId = uuidv4();
fs.writeFileSync(configFile, deviceId);
return deviceId;
}
// Get uid: use address from auth if logged in, otherwise use deviceId
export function getUid(): string {
const auth = getAuthConfig();
if (auth?.address) {
return auth.address;
}
return getDeviceId();
}
+294
View File
@@ -0,0 +1,294 @@
import chalk from 'chalk';
import figlet from 'figlet';
// show ASCII art banner
function showBanner(): void {
console.log(
chalk.cyan(
figlet.textSync("Pinme CLI", { horizontalLayout: "full" })
)
);
console.log(chalk.cyan("A command-line tool for uploading files to IPFS\n"));
}
// general help
function showGeneralHelp(): void {
showBanner();
console.log("USAGE:");
console.log(" pinme [command] [options]\n");
console.log("COMMANDS:");
console.log(" upload Upload a file or directory to IPFS");
console.log(" bind Upload and bind to a domain (requires wallet balance)");
console.log(" login Login via browser (recommended)");
console.log(" list Show upload history");
console.log(" ls Alias for 'list' command");
console.log(" set-appkey Set AppKey for authentication (alternative)");
console.log(" show-appkey Show current AppKey information (masked)");
console.log(" appkey Alias for 'show-appkey' command");
console.log(" wallet Show current wallet balance");
console.log(" wallet-balance Alias for 'wallet' command");
console.log(" balance Alias for 'wallet' command");
console.log(" logout Log out and clear authentication");
console.log(" help [command] Show help for a specific command\n");
console.log("OPTIONS:");
console.log(" -v, --version Output the current version");
console.log(" -h, --help Display help for command\n");
console.log("For more information on a specific command, try:");
console.log(" pinme help [command]");
}
// bind command help
function showBindHelp(): void {
console.log("COMMAND:");
console.log(" bind - Upload and bind to a domain (requires wallet balance)\n");
console.log("USAGE:");
console.log(" pinme bind <path> [options]\n");
console.log("DESCRIPTION:");
console.log(" This command uploads files and binds them to a custom domain.");
console.log(" Domain binding deducts from your wallet balance.\n");
console.log("OPTIONS:");
console.log(" -d, --domain <name> Domain name to bind (required)");
console.log(" --dns Force DNS domain mode (optional, auto-detected)\n");
console.log("EXAMPLES:");
console.log(" # Interactive mode (will prompt for path and domain)");
console.log(" pinme bind\n");
console.log(" # Bind to a Pinme subdomain (auto-detected: no dot in domain)");
console.log(" pinme bind ./dist --domain my-site\n");
console.log(" # Bind to a DNS domain (auto-detected: contains dot)");
console.log(" pinme bind ./dist --domain example.com\n");
console.log(" # Force DNS mode with --dns flag");
console.log(" pinme bind ./dist --domain my-site --dns\n");
console.log("AUTO-DETECTION:");
console.log(" - Domains with a dot (e.g., example.com) are treated as DNS domains");
console.log(" - Domains without a dot (e.g., my-site) are treated as Pinme subdomains");
console.log(" - Use --dns flag to force DNS domain mode\n");
console.log("REQUIREMENTS:");
console.log(" - Sufficient wallet balance is required for domain binding");
console.log(" - Valid AppKey must be set (run: pinme set-appkey <AppKey>)");
console.log(" - For DNS domains, you must own the domain\n");
console.log("URL FORMATS:");
console.log(" - Pinme subdomain: https://<name>.pinit.eth.limo");
console.log(" - DNS domain: https://<your-domain>.com\n");
console.log("DNS SETUP:");
console.log(" After successful DNS binding, visit:");
console.log(" https://pinme.eth.limo/#/docs?id=custom-domain");
console.log(" for DNS configuration guide.\n");
}
// upload command help
function showUploadHelp(): void {
console.log("COMMAND:");
console.log(" upload - Upload a file or directory to IPFS\n");
console.log("USAGE:");
console.log(" pinme upload [path] [options]\n");
console.log("DESCRIPTION:");
console.log(" This command uploads files or directories to IPFS.");
console.log(" If no path is provided, it will start in interactive mode.");
console.log(" Supports --domain option to bind a custom domain after upload (requires wallet balance).\n");
console.log("OPTIONS:");
console.log(" -d, --domain <name> Domain name to bind (optional, requires wallet balance)");
console.log(" --dns Force DNS domain mode (optional, auto-detected)\n");
console.log("EXAMPLES:");
console.log(" # Upload without binding");
console.log(" pinme upload");
console.log(" pinme upload ./my-website\n");
console.log(" # Upload and bind to a Pinme subdomain (auto-detected: no dot)");
console.log(" pinme upload ./dist --domain my-site\n");
console.log(" # Upload and bind to a DNS domain (auto-detected: contains dot)");
console.log(" pinme upload ./dist --domain example.com\n");
console.log(" # Force DNS mode with --dns flag");
console.log(" pinme upload ./dist --domain my-site --dns\n");
console.log("AUTO-DETECTION:");
console.log(" - Domains with a dot (e.g., example.com) are treated as DNS domains");
console.log(" - Domains without a dot (e.g., my-site) are treated as Pinme subdomains");
console.log(" - Use --dns flag to force DNS domain mode\n");
console.log("REQUIREMENTS:");
console.log(" - Sufficient wallet balance is required for domain binding");
console.log(" - Valid AppKey must be set (run: pinme login)");
console.log(" - For DNS domains, you must own the domain\n");
console.log("LIMITATIONS:");
console.log(" - Maximum file size: 10MB");
console.log(" - Maximum directory size: 500MB");
}
// login command help
function showLoginHelp(): void {
console.log("COMMAND:");
console.log(" login - Login via browser (recommended)\n");
console.log("USAGE:");
console.log(" pinme login\n");
console.log("DESCRIPTION:");
console.log(" This command opens the PinMe website in your browser for authentication.");
console.log(" After logging in, your token will be automatically saved.\n");
console.log("EXAMPLES:");
console.log(" pinme login\n");
console.log("HOW IT WORKS:");
console.log(" 1. CLI starts a local server");
console.log(" 2. Opens browser to PinMe login page");
console.log(" 3. User logs in on the website");
console.log(" 4. Website redirects back to CLI with token");
console.log(" 5. Token is saved locally\n");
console.log("NOTE:");
console.log(" - If you prefer manual input, use: pinme set-appkey <AppKey>");
console.log(" - Get your AppKey from: https://pinme.eth.limo/\n");
}
// list command help
function showListHelp(): void {
console.log("COMMAND:");
console.log(" list - Show upload history\n");
console.log("USAGE:");
console.log(" pinme list [options]\n");
console.log("OPTIONS:");
console.log(" -l, --limit <number> Limit the number of records to show");
console.log(" -c, --clear Clear all upload history\n");
console.log("EXAMPLES:");
console.log(" pinme list");
console.log(" pinme list -l 5");
console.log(" pinme list -c");
}
// ls command help (can reuse the list command help)
function showLsHelp(): void {
console.log("COMMAND:");
console.log(" ls - Alias for 'list' command\n");
console.log("USAGE:");
console.log(" pinme ls [options]\n");
console.log("OPTIONS:");
console.log(" -l, --limit <number> Limit the number of records to show");
console.log(" -c, --clear Clear all upload history\n");
console.log("EXAMPLES:");
console.log(" pinme ls");
console.log(" pinme ls -l 5");
console.log(" pinme ls -c");
}
// show-appkey command help
function showShowAppKeyHelp(): void {
console.log("COMMAND:");
console.log(" show-appkey - Show current AppKey information (masked)\n");
console.log("USAGE:");
console.log(" pinme show-appkey");
console.log(" pinme appkey\n");
console.log("DESCRIPTION:");
console.log(" This command displays the current AppKey information.");
console.log(" Sensitive information (token and AppKey) will be masked for security.\n");
console.log("EXAMPLES:");
console.log(" pinme show-appkey");
console.log(" pinme appkey");
}
function showWalletBalanceHelp(): void {
console.log("COMMAND:");
console.log(" wallet - Show current wallet balance\n");
console.log("USAGE:");
console.log(" pinme wallet");
console.log(" pinme wallet-balance");
console.log(" pinme balance\n");
console.log("DESCRIPTION:");
console.log(" This command fetches the current wallet balance for the logged-in account.");
console.log(" The value is displayed in USD.\n");
console.log("EXAMPLES:");
console.log(" pinme wallet");
console.log(" pinme wallet-balance");
console.log(" pinme balance");
}
// logout command help
function showLogoutHelp(): void {
console.log("COMMAND:");
console.log(" logout - Log out and clear authentication\n");
console.log("USAGE:");
console.log(" pinme logout\n");
console.log("DESCRIPTION:");
console.log(" This command logs out the current user and clears the authentication");
console.log(" information from local storage. You will need to set AppKey again");
console.log(" to use authenticated features.\n");
console.log("EXAMPLES:");
console.log(" pinme logout");
}
// show the help for the command
function showHelp(command?: string): void {
if (!command) {
showGeneralHelp();
return;
}
switch (command) {
case 'bind':
showBindHelp();
break;
case 'upload':
showUploadHelp();
break;
case 'login':
showLoginHelp();
break;
case 'list':
showListHelp();
break;
case 'ls':
showLsHelp();
break;
case 'show-appkey':
case 'appkey':
showShowAppKeyHelp();
break;
case 'wallet':
case 'wallet-balance':
case 'balance':
showWalletBalanceHelp();
break;
case 'logout':
showLogoutHelp();
break;
default:
console.log(`Unknown command: ${command}`);
showGeneralHelp();
}
}
export {
showHelp,
showBanner
};
+249
View File
@@ -0,0 +1,249 @@
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import dayjs from 'dayjs';
import chalk from 'chalk';
import { formatSize } from './uploadLimits';
import { getRootDomain } from './pinmeApi';
import tracker, { getTrackErrorReason } from './tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './trackerEvents';
// history file path
const HISTORY_DIR = path.join(os.homedir(), '.pinme');
const HISTORY_FILE = path.join(HISTORY_DIR, 'upload-history.json');
interface UploadRecord {
timestamp: number;
date: string;
path: string;
filename: string;
contentHash: string;
previewHash: string | null;
size: number;
fileCount: number;
type: 'directory' | 'file';
shortUrl?: string | null;
pinmeUrl?: string | null;
dnsUrl?: string | null;
}
interface UploadHistory {
uploads: UploadRecord[];
}
interface UploadData {
path: string;
filename?: string;
contentHash: string;
previewHash?: string | null;
size: number;
fileCount?: number;
isDirectory?: boolean;
shortUrl?: string | null;
pinmeUrl?: string | null;
dnsUrl?: string | null;
}
// ensure the history directory exists
const ensureHistoryDir = (): void => {
if (!fs.existsSync(HISTORY_DIR)) {
fs.mkdirSync(HISTORY_DIR, { recursive: true });
}
if (!fs.existsSync(HISTORY_FILE)) {
fs.writeJsonSync(HISTORY_FILE, { uploads: [] });
}
};
// save the upload history
const saveUploadHistory = (uploadData: UploadData): boolean => {
try {
ensureHistoryDir();
const history = fs.readJsonSync(HISTORY_FILE) as UploadHistory;
// add new upload record
const newRecord: UploadRecord = {
timestamp: Date.now(),
date: dayjs().format('YYYY-MM-DD HH:mm:ss'),
path: uploadData.path,
filename: uploadData.filename || path.basename(uploadData.path),
contentHash: uploadData.contentHash,
previewHash: uploadData.previewHash,
size: uploadData.size,
fileCount: uploadData.fileCount || 1,
type: uploadData.isDirectory ? 'directory' : 'file',
shortUrl: uploadData?.shortUrl || null,
pinmeUrl: uploadData?.pinmeUrl || null,
dnsUrl: uploadData?.dnsUrl || null,
};
history.uploads.unshift(newRecord); // add to the beginning
// write to file
fs.writeJsonSync(HISTORY_FILE, history, { spaces: 2 });
return true;
} catch (error: any) {
console.error(chalk.red(`Error saving upload history: ${error.message}`));
return false;
}
};
// get the upload history
const getUploadHistory = (limit: number = 10): UploadRecord[] => {
try {
ensureHistoryDir();
const history = fs.readJsonSync(HISTORY_FILE) as UploadHistory;
return history.uploads.slice(0, limit);
} catch (error: any) {
console.error(chalk.red(`Error reading upload history: ${error.message}`));
return [];
}
};
async function formatHistoryUrl(
value?: string | null,
options?: { appendRootDomain?: boolean; rootDomain?: string | null },
): Promise<string | null> {
if (!value) {
return null;
}
const normalized = value.trim();
if (!normalized) {
return null;
}
if (/^https?:\/\//.test(normalized)) {
if (
options?.appendRootDomain &&
options.rootDomain &&
(() => {
try {
return !new URL(normalized).hostname.includes('.');
} catch {
return false;
}
})()
) {
const url = new URL(normalized);
url.hostname = `${url.hostname}.${options.rootDomain}`;
return url.toString().replace(/\/$/, '');
}
return normalized.replace(/\/$/, '');
}
if (normalized.includes('.')) {
return `https://${normalized}`;
}
if (options?.appendRootDomain && options.rootDomain) {
return `https://${normalized}.${options.rootDomain}`;
}
return `https://${normalized}`;
}
// display the upload history
const displayUploadHistory = async (limit: number = 10): Promise<void> => {
try {
const history = getUploadHistory(limit);
void tracker.trackEvent(TRACK_EVENTS.uploadHistoryViewed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadHistoryViewed),
record_count: history.length,
limit,
});
if (history.length === 0) {
console.log(chalk.yellow('No upload history found.'));
return;
}
console.log(chalk.cyan('Upload History:'));
console.log(chalk.cyan('-'.repeat(80)));
let rootDomain: string | null = null;
try {
rootDomain = await getRootDomain();
} catch {
rootDomain = null;
}
// Display recent records, up to limit records
const recentHistory = history.slice(-limit);
for (const [index, item] of recentHistory.entries()) {
console.log(chalk.green(`${index + 1}. ${item.filename}`));
console.log(chalk.white(` Path: ${item.path}`));
console.log(chalk.white(` IPFS CID: ${item.contentHash}`));
const preferredUrl =
(await formatHistoryUrl(item.dnsUrl)) ||
(await formatHistoryUrl(item.pinmeUrl, {
appendRootDomain: true,
rootDomain,
})) ||
(await formatHistoryUrl(item.shortUrl, {
appendRootDomain: true,
rootDomain,
}));
if (preferredUrl) {
console.log(chalk.white(` URL: ${preferredUrl}`));
}
console.log(chalk.white(` Size: ${formatSize(item.size)}`));
console.log(chalk.white(` Files: ${item.fileCount}`));
console.log(chalk.white(` Type: ${item.type === 'directory' ? 'Directory' : 'File'}`));
if (item.timestamp) {
console.log(chalk.white(` Date: ${new Date(item.timestamp).toLocaleString()}`));
}
console.log(chalk.cyan('-'.repeat(80)));
}
// display the statistics
const totalSize = history.reduce((sum, record) => sum + record.size, 0);
const totalFiles = history.reduce((sum, record) => sum + record.fileCount, 0);
console.log(chalk.bold(`Total Uploads: ${history.length}`));
console.log(chalk.bold(`Total Files: ${totalFiles}`));
console.log(chalk.bold(`Total Size: ${formatSize(totalSize)}`));
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.uploadHistoryFailed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadHistoryFailed),
action: 'view',
reason: getTrackErrorReason(error),
});
throw error;
}
};
// clear the upload history
const clearUploadHistory = (): boolean => {
try {
ensureHistoryDir();
fs.writeJsonSync(HISTORY_FILE, { uploads: [] });
void tracker.trackEvent(TRACK_EVENTS.uploadHistoryCleared, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadHistoryCleared),
cleared: true,
});
console.log(chalk.green('Upload history cleared successfully.'));
return true;
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.uploadHistoryFailed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadHistoryFailed),
action: 'clear',
reason: getTrackErrorReason(error),
});
console.error(chalk.red(`Error clearing upload history: ${error.message}`));
return false;
}
};
export {
saveUploadHistory,
getUploadHistory,
displayUploadHistory,
clearUploadHistory,
formatHistoryUrl,
};
+503
View File
@@ -0,0 +1,503 @@
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import chalk from 'chalk';
import { spawn } from 'child_process';
type InstallScript = 'ci' | 'install';
interface InstallProjectDependenciesOptions {
mode?: 'auto' | InstallScript;
}
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
const SLOW_INSTALL_NOTICE_MS = 60 * 1000;
const CAPTURED_OUTPUT_LIMIT = 12000;
const STALE_LOG_ONLY_MARKER_MS = 90 * 1000;
const STOP_BACKGROUND_GRACE_MS = 3000;
const STOP_BACKGROUND_KILL_GRACE_MS = 2000;
const STOP_BACKGROUND_POLL_MS = 200;
// Marker files written by the background install so other commands (`pinme save`)
// can tell whether dependencies are still installing, finished, or failed.
export const INSTALL_LOG_FILE = '.pinme-install.log';
export const INSTALL_EXITCODE_FILE = '.pinme-install.exitcode';
export const INSTALL_PID_FILE = '.pinme-install.pid';
export type BackgroundInstallStatus =
| 'idle'
| 'running'
| 'success'
| 'failed'
| 'interrupted';
export interface BackgroundInstallState {
status: BackgroundInstallStatus;
exitCode: number | null;
logPath: string;
}
export class DependencyInstallError extends Error {
command: string;
constructor(command: string, cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause);
super(`${command} failed: ${reason}`);
this.name = 'DependencyInstallError';
this.command = command;
this.cause = cause;
}
}
function makeTempCacheDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'pinme-npm-cache-'));
}
function getNpmCommand(): string {
// On Windows, npm is often installed as npm.cmd
if (process.platform === 'win32') {
return 'npm.cmd';
}
return 'npm';
}
function hasPackageLock(cwd: string): boolean {
return fs.existsSync(path.join(cwd, 'package-lock.json'));
}
function getInstallScript(cwd: string, mode: 'auto' | InstallScript): InstallScript {
if (mode !== 'auto') {
return mode;
}
return hasPackageLock(cwd) ? 'ci' : 'install';
}
function getInstallArgs(script: InstallScript, cacheDir?: string): string[] {
const args = [
script,
'--no-audit',
'--no-fund',
'--fetch-retries=3',
'--fetch-retry-factor=2',
'--fetch-retry-mintimeout=10000',
'--fetch-retry-maxtimeout=60000',
'--fetch-timeout=60000',
];
if (cacheDir) {
args.splice(1, 0, '--cache', cacheDir);
}
return args;
}
function formatInstallCommand(
script: InstallScript,
cacheMode: 'default' | 'isolated' = 'default',
): string {
const parts = [
`npm ${script}`,
'--no-audit',
'--no-fund',
'--fetch-retries=3',
'--fetch-timeout=60000',
];
if (cacheMode === 'isolated') {
parts.splice(1, 0, '--cache <isolated npm cache>');
}
return parts.join(' ');
}
function appendCapturedOutput(output: string, chunk: Buffer): string {
const next = output + chunk.toString();
if (next.length <= CAPTURED_OUTPUT_LIMIT) {
return next;
}
return next.slice(next.length - CAPTURED_OUTPUT_LIMIT);
}
function runInstall(
cwd: string,
script: InstallScript,
cacheDir?: string,
): Promise<void> {
const npm = getNpmCommand();
const args = getInstallArgs(script, cacheDir);
const env: NodeJS.ProcessEnv = {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
npm_config_fetch_retries: '3',
npm_config_fetch_retry_factor: '2',
npm_config_fetch_retry_mintimeout: '10000',
npm_config_fetch_retry_maxtimeout: '60000',
npm_config_fetch_timeout: '60000',
};
if (cacheDir) {
env.npm_config_cache = cacheDir;
}
return new Promise((resolve, reject) => {
let capturedOutput = '';
let settled = false;
let timedOut = false;
const child = spawn(npm, args, {
cwd,
stdio: ['inherit', 'pipe', 'pipe'],
shell: process.platform === 'win32',
env,
});
const slowNoticeTimer = setTimeout(() => {
console.log(chalk.yellow(' Still installing dependencies. Slow npm registry or network can make this take a few minutes...'));
}, SLOW_INSTALL_NOTICE_MS);
const timeoutTimer = setTimeout(() => {
timedOut = true;
child.kill();
}, INSTALL_TIMEOUT_MS);
const cleanup = () => {
clearTimeout(slowNoticeTimer);
clearTimeout(timeoutTimer);
};
child.stdout?.on('data', (chunk: Buffer) => {
process.stdout.write(chunk);
capturedOutput = appendCapturedOutput(capturedOutput, chunk);
});
child.stderr?.on('data', (chunk: Buffer) => {
process.stderr.write(chunk);
capturedOutput = appendCapturedOutput(capturedOutput, chunk);
});
child.on('error', (error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
});
child.on('close', (code, signal) => {
if (settled) return;
settled = true;
cleanup();
if (timedOut) {
reject(new Error(`npm ${script} timed out after 10 minutes.\n${capturedOutput}`));
return;
}
if (code !== 0) {
reject(new Error(`npm ${script} failed with exit code ${code}${signal ? ` and signal ${signal}` : ''}.\n${capturedOutput}`));
return;
}
resolve();
});
});
}
type ShellPlatform = NodeJS.Platform | 'posix';
interface BackgroundInstallCommand {
shellBin: string;
shellArgs: string[];
installCommand: string;
}
function quoteForShell(value: string, platform: ShellPlatform = process.platform): string {
// tmp paths / project paths can contain spaces; wrap everything in quotes.
if (platform === 'win32') {
return `"${value}"`;
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function buildBackgroundInstallCommand(
script: InstallScript,
logPath: string,
exitCodePath: string,
platform: ShellPlatform = process.platform,
): BackgroundInstallCommand {
const args = getInstallArgs(script);
const npmCmd = platform === 'win32' ? 'npm' : getNpmCommand();
const installCommand = `${npmCmd} ${args.map((arg) => quoteForShell(arg, platform)).join(' ')}`;
const qLog = quoteForShell(logPath, platform);
const qExit = quoteForShell(exitCodePath, platform);
if (platform === 'win32') {
// Enable delayed expansion so !errorlevel! is evaluated after npm exits.
return {
shellBin: process.env.ComSpec || 'cmd.exe',
shellArgs: [
'/d',
'/s',
'/v:on',
'/c',
`${installCommand} >> ${qLog} 2>&1 & echo !errorlevel! > ${qExit}`,
],
installCommand,
};
}
return {
shellBin: '/bin/sh',
shellArgs: [
'-c',
`${installCommand} >> ${qLog} 2>&1; printf '%s' "$?" > ${qExit}`,
],
installCommand,
};
}
/**
* Start a dependency install in a detached background process and return
* immediately. The child keeps running after the current CLI process exits,
* so `pinme create` can finish (and `process.exit`) while dependencies keep
* installing for later use (`pinme save`, local dev).
*
* The install is wrapped in a shell command that:
* 1. Clears any previous exit-code marker.
* 2. Streams all output to `<cwd>/.pinme-install.log`.
* 3. Writes the install's exit code to `<cwd>/.pinme-install.exitcode` when done.
*
* Other commands read those markers (see {@link readBackgroundInstallStatus}) to
* know whether the install is still running, succeeded, or failed.
*/
export function startBackgroundInstall(cwd: string): { logPath: string } {
const script = getInstallScript(cwd, 'auto');
const logPath = path.join(cwd, INSTALL_LOG_FILE);
const exitCodePath = path.join(cwd, INSTALL_EXITCODE_FILE);
const pidPath = path.join(cwd, INSTALL_PID_FILE);
// Reset markers from any previous run.
fs.removeSync(exitCodePath);
fs.removeSync(pidPath);
fs.writeFileSync(logPath, `[pinme] ${new Date().toISOString()} starting "npm ${script}"\n`);
const { shellBin, shellArgs } = buildBackgroundInstallCommand(script, logPath, exitCodePath);
const child = spawn(shellBin, shellArgs, {
cwd,
detached: true,
stdio: 'ignore',
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
npm_config_fetch_retries: '3',
npm_config_fetch_retry_factor: '2',
npm_config_fetch_retry_mintimeout: '10000',
npm_config_fetch_retry_maxtimeout: '60000',
npm_config_fetch_timeout: '60000',
},
});
child.unref();
if (child.pid) {
fs.writeFileSync(pidPath, String(child.pid));
}
return { logPath };
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error: any) {
return error?.code === 'EPERM';
}
}
function readPidFile(pidPath: string): number | null {
if (!fs.existsSync(pidPath)) {
return null;
}
const raw = fs.readFileSync(pidPath, 'utf-8').trim();
const pid = Number.parseInt(raw, 10);
if (!Number.isFinite(pid) || pid <= 0) {
return null;
}
return pid;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function signalInstallProcess(pid: number, signal: NodeJS.Signals): boolean {
try {
if (process.platform === 'win32') {
process.kill(pid, signal);
} else {
process.kill(-pid, signal);
}
return true;
} catch {
try {
process.kill(pid, signal);
return true;
} catch {
return false;
}
}
}
async function waitForProcessExit(
pid: number,
timeoutMs: number,
): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (!isProcessAlive(pid)) {
return true;
}
await sleep(STOP_BACKGROUND_POLL_MS);
}
return !isProcessAlive(pid);
}
export async function stopBackgroundInstall(cwd: string): Promise<boolean> {
const pidPath = path.join(cwd, INSTALL_PID_FILE);
const pid = readPidFile(pidPath);
let stopped = false;
if (pid !== null && isProcessAlive(pid)) {
if (signalInstallProcess(pid, 'SIGTERM')) {
stopped = true;
const stoppedGracefully = await waitForProcessExit(
pid,
STOP_BACKGROUND_GRACE_MS,
);
if (!stoppedGracefully) {
signalInstallProcess(pid, 'SIGKILL');
await waitForProcessExit(pid, STOP_BACKGROUND_KILL_GRACE_MS);
}
}
}
fs.removeSync(pidPath);
return stopped;
}
function isStaleLogOnlyMarker(logPath: string): boolean {
try {
const ageMs = Date.now() - fs.statSync(logPath).mtimeMs;
return ageMs > STALE_LOG_ONLY_MARKER_MS;
} catch {
return false;
}
}
/**
* Read the state of a background install started by {@link startBackgroundInstall}.
* - `idle`: no install was ever started here (no log, no exit-code marker).
* - `running`: a log exists but the exit code has not been written yet.
* - `success` / `failed`: the install finished with the recorded exit code.
* - `interrupted`: an install started, but the tracked background process is
* gone or a legacy log-only marker is too old to trust.
*/
export function readBackgroundInstallStatus(cwd: string): BackgroundInstallState {
const logPath = path.join(cwd, INSTALL_LOG_FILE);
const exitCodePath = path.join(cwd, INSTALL_EXITCODE_FILE);
const pidPath = path.join(cwd, INSTALL_PID_FILE);
if (fs.existsSync(exitCodePath)) {
const raw = fs.readFileSync(exitCodePath, 'utf-8').trim();
const code = Number.parseInt(raw, 10);
if (Number.isNaN(code)) {
// The marker is being written but the value is not flushed yet.
return { status: 'running', exitCode: null, logPath };
}
return { status: code === 0 ? 'success' : 'failed', exitCode: code, logPath };
}
if (fs.existsSync(logPath)) {
const pid = readPidFile(pidPath);
if (pid !== null) {
return {
status: isProcessAlive(pid) ? 'running' : 'interrupted',
exitCode: null,
logPath,
};
}
if (isStaleLogOnlyMarker(logPath)) {
return { status: 'interrupted', exitCode: null, logPath };
}
return { status: 'running', exitCode: null, logPath };
}
return { status: 'idle', exitCode: null, logPath };
}
/** Return the tail of the background install log, for surfacing failure causes. */
export function readBackgroundInstallLogTail(cwd: string, maxChars = 1500): string {
const logPath = path.join(cwd, INSTALL_LOG_FILE);
if (!fs.existsSync(logPath)) {
return '';
}
const content = fs.readFileSync(logPath, 'utf-8');
return content.length > maxChars ? content.slice(content.length - maxChars) : content;
}
export async function installProjectDependencies(
cwd: string,
options: InstallProjectDependenciesOptions = {},
): Promise<void> {
const mode = options.mode || 'auto';
const script = getInstallScript(cwd, mode);
const command = formatInstallCommand(script);
let lastError: unknown;
if (script === 'ci' && !hasPackageLock(cwd)) {
throw new DependencyInstallError(
command,
new Error('package-lock.json is required for npm ci but was not found.'),
);
}
for (let attempt = 1; attempt <= 2; attempt += 1) {
const useIsolatedCache = attempt > 1;
const cacheDir = useIsolatedCache ? makeTempCacheDir() : undefined;
try {
if (attempt > 1) {
console.log(chalk.yellow(' Retrying dependency install with a fresh npm cache...'));
}
if (attempt === 1 && script === 'ci' && mode === 'auto') {
console.log(chalk.gray(' package-lock.json found; using npm ci for a reproducible install.'));
} else if (attempt === 1 && script === 'ci') {
console.log(chalk.gray(' Using npm ci for a clean, reproducible install.'));
}
await runInstall(cwd, script, cacheDir);
return;
} catch (error) {
lastError = error;
} finally {
if (cacheDir) {
fs.removeSync(cacheDir);
}
}
}
throw new DependencyInstallError(
formatInstallCommand(script, 'isolated'),
lastError,
);
}
+405
View File
@@ -0,0 +1,405 @@
import chalk from 'chalk';
import { createCarApiClient, createPinmeApiClient } from './apiClient';
import { APP_CONFIG } from './config';
import { isDnsDomain } from './domainValidator';
// Token expired error codes
const TOKEN_EXPIRED_CODES = [
401,
403,
10001,
10002,
20001,
'TOKEN_EXPIRED',
'AUTH_FAILED',
];
const TOKEN_EXPIRED_MESSAGES = [
'token expired',
'invalid token',
'authentication failed',
'auth failed',
'unauthorized',
'登录',
'过期',
'token',
'鉴权',
];
function isTokenExpired(error: any): boolean {
const status = error.response?.status;
const message =
error.response?.data?.msg ||
error.response?.data?.message ||
error.message ||
'';
const code = error.response?.data?.code;
if (status && TOKEN_EXPIRED_CODES.includes(status)) {
return true;
}
if (code && TOKEN_EXPIRED_CODES.includes(code)) {
return true;
}
const lowerMessage = message.toLowerCase();
return TOKEN_EXPIRED_MESSAGES.some((m) =>
lowerMessage.includes(m.toLowerCase()),
);
}
function showTokenExpiredHint(): void {
console.log(chalk.red('\n⚠️ Token has expired or is invalid.'));
console.log(chalk.yellow('Please re-run: pinme set-appkey <AppKey>\n'));
}
export async function bindAnonymousDevice(
anonymousUid: string,
): Promise<boolean> {
try {
const client = createPinmeApiClient();
const { data } = await client.post('/bind_anonymous', {
anonymous_uid: anonymousUid,
});
return data?.code === 200;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
return false;
}
console.log(
chalk.yellow(`Failed to trigger anonymous binding: ${e?.message || e}`),
);
return false;
}
}
export interface CheckDomainResult {
is_valid: boolean;
error?: string;
}
export interface GetRootDomainResponse {
code: number;
msg: string;
data: {
domain: string;
};
}
let rootDomainCache: string | null = null;
export async function getRootDomain(
forceRefresh: boolean = false,
): Promise<string> {
if (!forceRefresh && rootDomainCache) {
return rootDomainCache;
}
const client = createPinmeApiClient();
const { data } = await client.get<GetRootDomainResponse>('/root_domain');
if (data?.code === 200 && data?.data?.domain) {
rootDomainCache = data.data.domain;
return rootDomainCache;
}
throw new Error(data?.msg || 'Failed to get root domain');
}
export async function checkDomainAvailable(
domainName: string,
): Promise<CheckDomainResult> {
if (isDnsDomain(domainName)) {
return await {
is_valid: true,
};
}
const client = createPinmeApiClient();
// Endpoint may not be fixed, prioritize environment variable, then try two common paths
const configured = APP_CONFIG.pinmeCheckDomainPath;
const fallbacks = [configured];
let lastRecoverableError: any;
for (const p of fallbacks) {
try {
const { data } = await client.post(p, { domain_name: domainName });
if (typeof data?.is_valid === 'boolean') {
return { is_valid: data.is_valid, error: data?.error };
}
if (data?.data && typeof data.data.is_valid === 'boolean') {
return { is_valid: data.data.is_valid, error: data.data?.error };
}
// Unexpected structure, continue trying next path
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
const status = e?.response?.status;
if (status && ![404, 405].includes(status)) {
throw e;
}
lastRecoverableError = e;
}
}
if (lastRecoverableError) {
throw lastRecoverableError;
}
// If all attempts fail silently, return unknown state and let the bind step decide.
return { is_valid: true };
}
export async function bindPinmeDomain(
domainName: string,
hash: string,
projectName?: string,
): Promise<boolean> {
try {
const client = createPinmeApiClient();
const { data } = await client.post('/bind_pinme_domain', {
domain_name: domainName,
hash,
...(projectName ? { project_name: projectName } : {}),
});
return data?.code === 200;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface MyDomainItem {
domain_name: string;
domain_type: number;
bind_time: number;
expire_time: number;
}
export async function getMyDomains(): Promise<MyDomainItem[]> {
try {
const client = createPinmeApiClient();
const { data } = await client.get('/my_domains');
if (data?.code === 200) {
if (Array.isArray(data?.data)) {
// v4: data is array
return data.data as MyDomainItem[];
}
if (data?.data?.list && Array.isArray(data.data.list)) {
// fallback: sometimes wrapped in { list: [] }
return data.data.list as MyDomainItem[];
}
}
if (data?.code === 401 || data?.code === 403) {
showTokenExpiredHint();
throw new Error('Token expired');
}
return [];
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface BindDnsDomainV4Response {
code: number;
msg: string;
data?: {
domain_name: string;
hash: string;
bind_time?: number;
};
}
export async function bindDnsDomainV4(
domainName: string,
hash: string,
tokenAddress: string,
authToken: string,
projectName?: string,
): Promise<BindDnsDomainV4Response> {
try {
const client = createPinmeApiClient();
const { data } = await client.post<BindDnsDomainV4Response>(
'/bind_dns',
{
domain_name: domainName,
hash: hash,
...(projectName ? { project_name: projectName } : {}),
},
{
headers: {
'x-auth-token': authToken,
'x-token-address': tokenAddress,
},
},
);
return data as BindDnsDomainV4Response;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface WalletBalanceResponse {
code: number;
msg: string;
data?: {
wallet_balance_usd?: number;
};
}
export async function getWalletBalance(
tokenAddress: string,
authToken: string,
): Promise<WalletBalanceResponse> {
try {
const client = createPinmeApiClient();
const { data } = await client.get<WalletBalanceResponse>(
'/pay/wallet/balance',
{
headers: {
'authentication-tokens': authToken,
'token-address': tokenAddress,
},
},
);
return data as WalletBalanceResponse;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface IsVipResponse {
code: number;
msg: string;
data?: {
is_vip: boolean;
vip_expire_time?: number;
};
}
export async function isVip(
tokenAddress: string,
authToken: string,
): Promise<IsVipResponse> {
try {
const client = createPinmeApiClient();
const { data } = await client.get<IsVipResponse>('/is_vip', {
headers: {
'x-auth-token': authToken,
'x-token-address': tokenAddress,
},
});
return data as IsVipResponse;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface CarExportResponse {
code: number;
msg: string;
data: {
cid: string;
status: string;
task_id: string;
};
}
export interface CarExportStatusResponse {
code: number;
msg: string;
data: {
task_id: string;
cid: string;
status: 'processing' | 'completed' | 'failed';
download_url?: string;
};
}
export async function requestCarExport(
cid: string,
uid: string,
): Promise<CarExportResponse['data']> {
try {
const client = createCarApiClient();
// Use POST method as shown in the example
const { data } = await client.post<CarExportResponse>('/car/export', null, {
params: {
cid,
uid,
},
});
if (data?.code === 200 && data?.data) {
return data.data;
}
throw new Error(data?.msg || 'Failed to request CAR export');
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
if (e.response?.data?.msg) {
throw new Error(e.response.data.msg);
}
throw new Error(`Failed to request CAR export: ${e?.message || e}`);
}
}
export interface BindDnsDomainV4Response {
code: number;
msg: string;
data?: {
domain_name: string;
hash: string;
bind_time?: number;
};
}
export async function checkCarExportStatus(
taskId: string,
): Promise<CarExportStatusResponse['data']> {
try {
const client = createCarApiClient();
const { data } = await client.get<CarExportStatusResponse>(
'/car/export/status',
{
params: {
task_id: taskId,
},
},
);
if (data?.code === 200 && data?.data) {
return data.data;
}
throw new Error(data?.msg || 'Failed to check export status');
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
if (e.response?.data?.msg) {
throw new Error(e.response.data.msg);
}
throw new Error(`Failed to check export status: ${e?.message || e}`);
}
}
+139
View File
@@ -0,0 +1,139 @@
import fs from 'fs-extra';
import path from 'path';
import { createConfigError } from './cliError';
const PLACEHOLDERS = {
apiUrl: '__PINME_VITE_API_URL__',
authApiKey: '__PINME_AUTH_API_KEY__',
authDomain: '__PINME_AUTH_DOMAIN__',
authProjectId: '__PINME_AUTH_PROJECT_ID__',
tenantId: '__PINME_TENANT_ID__',
} as const;
const PATCHABLE_EXTENSIONS = new Set([
'.html',
'.js',
'.css',
'.json',
'.map',
]);
interface PrebuiltDistWorkerData {
api_domain?: string;
public_client_config?: Record<string, any>;
}
export interface PatchPrebuiltFrontendDistResult {
filesScanned: number;
filesPatched: number;
apiUrlReplacements: number;
authReplacements: number;
}
function toReplacementValue(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return String(value);
}
function countOccurrences(content: string, needle: string): number {
if (!needle) {
return 0;
}
return content.split(needle).length - 1;
}
function listPatchableFiles(dir: string): string[] {
const result: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
result.push(...listPatchableFiles(entryPath));
continue;
}
if (entry.isFile() && PATCHABLE_EXTENSIONS.has(path.extname(entry.name))) {
result.push(entryPath);
}
}
return result;
}
export function patchPrebuiltFrontendDist(
frontendDistDir: string,
workerData: PrebuiltDistWorkerData,
): PatchPrebuiltFrontendDistResult {
if (!workerData.api_domain) {
throw createConfigError('`api_domain` is missing from project creation response.', [
'Retry `pinme create`.',
'If the problem persists, check the `/create_worker` API response.',
]);
}
if (!fs.existsSync(frontendDistDir)) {
throw createConfigError('Prebuilt frontend output not found: `frontend/dist/`.', [
'The template should ship a prebuilt `frontend/dist/`.',
'Once dependencies finish installing, run `npm run build:frontend` in the project, then `pinme save`.',
]);
}
const authConfig = workerData.public_client_config ?? {};
const replacements = new Map<string, string>([
[PLACEHOLDERS.apiUrl, workerData.api_domain],
[PLACEHOLDERS.authApiKey, toReplacementValue(authConfig.auth_api_key)],
[PLACEHOLDERS.authDomain, toReplacementValue(authConfig.auth_domain)],
[PLACEHOLDERS.authProjectId, toReplacementValue(authConfig.auth_project_id)],
[PLACEHOLDERS.tenantId, toReplacementValue(authConfig.tenant_id)],
]);
const files = listPatchableFiles(frontendDistDir);
let filesPatched = 0;
let apiUrlReplacements = 0;
let authReplacements = 0;
for (const filePath of files) {
const content = fs.readFileSync(filePath, 'utf8');
let nextContent = content;
for (const [placeholder, replacement] of replacements) {
const count = countOccurrences(nextContent, placeholder);
if (count === 0) {
continue;
}
if (placeholder === PLACEHOLDERS.apiUrl) {
apiUrlReplacements += count;
} else {
authReplacements += count;
}
nextContent = nextContent.split(placeholder).join(replacement);
}
if (nextContent !== content) {
fs.writeFileSync(filePath, nextContent);
filesPatched += 1;
}
}
if (apiUrlReplacements === 0) {
throw createConfigError('Prebuilt frontend dist is missing required Pinme config placeholder.', [
'Expected to find `__PINME_VITE_API_URL__` in `frontend/dist`.',
'Rebuild the template frontend from placeholder-enabled source before publishing the template.',
'After dependencies finish installing, users can recover by running `npm run build:frontend` and `pinme save`.',
]);
}
return {
filesScanned: files.length,
filesPatched,
apiUrlReplacements,
authReplacements,
};
}
+105
View File
@@ -0,0 +1,105 @@
import axios from 'axios';
import chalk from 'chalk';
import { getUid } from './getDeviceId';
import { APP_CONFIG } from './config';
// Get API base URL from environment variables
const ipfsApiUrl = APP_CONFIG.ipfsApiUrl;
interface RemoveResponse {
code: number;
msg: string;
data: any;
}
/**
* Remove file from IPFS network
* @param value - IPFS content hash or subname
* @param type - Type of the value: 'hash' or 'subname'
* @returns Promise<boolean> - Whether deletion was successful
*/
export async function removeFromIpfs(
value: string,
type: 'hash' | 'subname' = 'hash',
): Promise<boolean> {
try {
const uid = getUid();
console.log(chalk.blue(`Removing content from IPFS: ${value}...`));
// Build query parameters based on type
const queryParams = new URLSearchParams({
uid: uid,
});
if (type === 'subname') {
queryParams.append('subname', value);
} else {
queryParams.append('arg', value);
}
const response = await axios.post<RemoveResponse>(
`${ipfsApiUrl}/block/rm?${queryParams.toString()}`,
{
timeout: 30000, // 30 seconds timeout
},
);
const { code, msg, data } = response.data;
if (code === 200) {
console.log(chalk.green('✓ Removal successful!'));
console.log(
chalk.cyan(
`Content ${type}: ${value} has been removed from IPFS network`,
),
);
return true;
} else {
console.log(chalk.red('✗ Removal failed'));
console.log(chalk.red(`Error: ${msg || 'Unknown error occurred'}`));
return false;
}
} catch (error: any) {
console.log(chalk.red('✗ Removal failed', error));
if (error.response) {
// Server responded with error status code
const { status, data } = error.response;
console.log(
chalk.red(`HTTP Error ${status}: ${data?.msg || 'Server error'}`),
);
if (status === 404) {
console.log(
chalk.yellow('Content not found on the network or already removed'),
);
} else if (status === 403) {
console.log(
chalk.yellow(
'Permission denied - you may not have access to remove this content',
),
);
} else if (status === 500) {
console.log(
chalk.yellow('Server internal error - please try again later'),
);
}
} else if (error.request) {
// Request was made but no response received
console.log(
chalk.red('Network error: Unable to connect to IPFS service'),
);
console.log(
chalk.yellow('Please check your internet connection and try again'),
);
} else {
// Other errors
console.log(chalk.red(`Error: ${error.message}`));
}
return false;
}
}
export default removeFromIpfs;
+375
View File
@@ -0,0 +1,375 @@
import os from 'os';
import fs from 'fs';
import path from 'path';
import { spawn } from 'child_process';
import { version } from '../../package.json';
import { getUid } from './getDeviceId';
export interface TrackData {
[key: string]: string | number | boolean | null | undefined;
}
const REQUEST_TIMEOUT_MS = 1500;
const TRACK_VALUE_LIMIT = 200;
const TRACK_REASON_LIMIT = 255;
const DEFAULT_GATEWAY = 'https://pinme.dev';
const DEFAULT_PRODUCT = 'pinme-cli';
const ACTION_OVERRIDES: Record<string, string> = {
cli_login_success: 'success',
cli_login_failed: 'fail',
appkey_set_success: 'success',
appkey_set_failed: 'fail',
appkey_shown_success: 'view',
appkey_shown_failed: 'fail',
my_domains_success: 'view',
my_domains_failed: 'fail',
wallet_balance_success: 'view',
wallet_balance_failed: 'fail',
upload_history_viewed: 'view',
upload_history_cleared: 'click',
upload_history_failed: 'fail',
};
const EV_OVERRIDES: Record<string, string> = {
upload_success: 'upload',
upload_failed: 'upload',
project_save_success: 'project_save',
project_save_failed: 'project_save',
};
const TRACK_CHILD_SCRIPT = `
const rawUrl = process.argv[1];
if (!rawUrl) process.exit(0);
try {
const transport = rawUrl.startsWith('https:') ? require('https') : require('http');
const req = transport.get(rawUrl, {
headers: {
'User-Agent': 'Pinme-CLI-Tracker'
}
}, (res) => {
res.resume();
res.on('end', () => process.exit(0));
});
req.setTimeout(${REQUEST_TIMEOUT_MS}, () => req.destroy());
req.on('error', () => process.exit(0));
req.on('close', () => process.exit(0));
} catch (_) {
process.exit(0);
}
`;
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
}
function shouldDisableTracking(): boolean {
return (
process.env.PINME_TRACKING_DISABLED === '1' ||
process.env.DO_NOT_TRACK === '1'
);
}
function sanitizeTrackValue(
value: unknown,
maxLength = TRACK_VALUE_LIMIT,
): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
return String(value).trim().slice(0, maxLength);
}
export function getTrackErrorReason(error: unknown): string {
return sanitizeTrackValue(resolveErrorReason(error), TRACK_REASON_LIMIT) || 'unknown_error';
}
function responseDataMessage(data: any): string | undefined {
if (typeof data === 'string') {
return data;
}
return data?.msg
|| data?.message
|| data?.error
|| data?.data?.msg
|| data?.data?.message
|| data?.data?.error
|| data?.errors?.[0]?.message;
}
function normalizeReason(candidate: unknown, status?: number): string | undefined {
const value = sanitizeTrackValue(candidate, TRACK_REASON_LIMIT);
if (!value) {
return undefined;
}
const lower = value.toLowerCase();
if (/^\s*<!doctype\s+html/i.test(value) || /^\s*<html[\s>]/i.test(value)) {
return 'api_returned_html';
}
const statusMatch = lower.match(/request failed with status code (\d{3})/);
const statusCode = status || (statusMatch ? Number(statusMatch[1]) : undefined);
if (statusCode === 520) {
return 'gateway_520';
}
if (
lower.includes('token authentication failed') ||
lower.includes('invalid token') ||
lower.includes('token expired') ||
lower.includes('authentication failed') ||
lower.includes('auth failed') ||
lower.includes('unauthorized')
) {
return 'token_auth_failed';
}
if (lower.includes('auth not set') || lower.includes('please login first')) {
return 'auth_not_set';
}
if (lower.includes('login timeout')) {
return 'login_timeout';
}
return value;
}
function resolveErrorReason(error: unknown, seen = new Set<unknown>()): string {
if (error === undefined || error === null || seen.has(error)) {
return 'unknown_error';
}
seen.add(error);
const maybeError = error as any;
const responseData = maybeError?.response?.data;
const responseStatus = maybeError?.response?.status;
const responseReason = normalizeReason(
responseDataMessage(responseData),
responseStatus,
);
if (responseReason) {
return responseReason;
}
if (maybeError?.cause) {
const causeReason = resolveErrorReason(maybeError.cause, seen);
if (causeReason && causeReason !== 'unknown_error') {
return causeReason;
}
}
const messageReason = normalizeReason(maybeError?.message, responseStatus);
if (messageReason) {
return messageReason;
}
const stringReason = normalizeReason(maybeError?.toString?.(), responseStatus);
return stringReason || 'unknown_error';
}
function resolveTrackAction(event: string, data: TrackData = {}): string {
const explicitAction = data.a;
if (typeof explicitAction === 'string' && explicitAction.trim()) {
return explicitAction.trim();
}
if (ACTION_OVERRIDES[event]) {
return ACTION_OVERRIDES[event];
}
if (event.endsWith('_success')) {
return 'success';
}
if (event.endsWith('_failed') || event.endsWith('_fail')) {
return 'fail';
}
if (event.includes('click') || event.includes('copied')) {
return 'click';
}
if (event.includes('exposure')) {
return 'exposure';
}
if (event.includes('view')) {
return 'view';
}
if (event.endsWith('_started') || event.endsWith('_submit')) {
return 'submit';
}
return 'view';
}
function resolveTrackEvent(event: string): string {
return EV_OVERRIDES[event] || event;
}
function resolveTrackReason(
data: TrackData,
): string | undefined {
return sanitizeTrackValue(data.re || data.reason, TRACK_REASON_LIMIT);
}
interface ProjectContext {
projectName?: string;
projectDir?: string;
}
let cachedProjectContext: ProjectContext | null = null;
let cachedProjectContextCwd: string | null = null;
function resolveProjectContext(): ProjectContext {
const cwd = process.cwd();
if (cachedProjectContext && cachedProjectContextCwd === cwd) {
return cachedProjectContext;
}
const context: ProjectContext = {};
const configPath = path.join(cwd, 'pinme.toml');
if (fs.existsSync(configPath)) {
try {
const configContent = fs.readFileSync(configPath, 'utf8');
const projectNameMatch = configContent.match(
/project_name\s*=\s*"([^"]+)"/,
);
context.projectName =
sanitizeTrackValue(projectNameMatch?.[1]) ||
sanitizeTrackValue(process.env.PINME_PROJECT_NAME);
context.projectDir = sanitizeTrackValue(path.basename(cwd));
} catch (_) {
context.projectName = sanitizeTrackValue(process.env.PINME_PROJECT_NAME);
}
} else {
context.projectName = sanitizeTrackValue(process.env.PINME_PROJECT_NAME);
}
cachedProjectContext = context;
cachedProjectContextCwd = cwd;
return context;
}
export function getPathKind(pathValue: string): string {
try {
const stat = fs.statSync(pathValue);
if (stat.isDirectory()) {
return 'directory';
}
if (stat.isFile()) {
return 'file';
}
} catch (_) {
return 'unknown';
}
return 'unknown';
}
class Tracker {
private static instance: Tracker;
private readonly gateway: string;
private readonly product: string;
private readonly source: string | undefined;
private readonly disabled: boolean;
private constructor(gateway?: string, product?: string) {
this.gateway = trimTrailingSlash(
gateway || process.env.PINME_TRACKER_GATEWAY || DEFAULT_GATEWAY,
);
this.product = product || DEFAULT_PRODUCT;
this.source = sanitizeTrackValue(process.env.PINME_TRACK_SOURCE);
this.disabled = shouldDisableTracking();
}
public static getInstance(gateway?: string, product?: string): Tracker {
if (!Tracker.instance) {
Tracker.instance = new Tracker(gateway, product);
}
return Tracker.instance;
}
public trackEvent(
event: string,
page: string,
data: TrackData = {},
): Promise<void> {
if (this.disabled || !this.gateway) {
return Promise.resolve();
}
try {
const payload = this.buildPayload(event, page, data);
const params = new URLSearchParams(payload).toString();
const url = `${this.gateway}/track.gif?${params}`;
this.dispatch(url);
} catch (_) {
// Tracking is best-effort and must never interrupt CLI flows.
}
return Promise.resolve();
}
private buildPayload(
event: string,
page: string,
data: TrackData,
): Record<string, string> {
const projectContext = resolveProjectContext();
const action = resolveTrackAction(event, data);
const ev = resolveTrackEvent(event);
const payload: TrackData = {
...data,
u: getUid(),
s: this.source,
pd: this.product,
p: page,
a: action,
ev,
event,
re: resolveTrackReason(data),
project_name: projectContext.projectName || data.project_name,
project_dir: projectContext.projectDir,
cli_version: version,
node_version: process.version,
os: os.platform(),
arch: os.arch(),
};
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(payload)) {
const normalized = sanitizeTrackValue(
value,
key === 're' ? TRACK_REASON_LIMIT : TRACK_VALUE_LIMIT,
);
if (normalized) {
filtered[key] = normalized;
}
}
return filtered;
}
private dispatch(url: string): void {
const child = spawn(process.execPath, ['-e', TRACK_CHILD_SCRIPT, url], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
}
}
const tracker = Tracker.getInstance();
export default tracker;
+114
View File
@@ -0,0 +1,114 @@
export const TRACK_PAGES = {
auth: 'cli_auth',
login: 'cli_login',
upload: 'cli_upload',
import: 'cli_import',
export: 'cli_export',
remove: 'cli_remove',
domain: 'cli_domain',
wallet: 'cli_wallet',
project: 'cli_project',
deploy: 'cli_deploy',
} as const;
export const TRACK_EVENTS = {
cliLoginSuccess: 'cli_login_success',
cliLoginFailed: 'cli_login_failed',
appKeySetSuccess: 'appkey_set_success',
appKeySetFailed: 'appkey_set_failed',
logoutSuccess: 'logout_success',
logoutFailed: 'logout_failed',
uploadSuccess: 'upload_success',
uploadFailed: 'upload_failed',
importSuccess: 'import_success',
importFailed: 'import_failed',
exportSuccess: 'export_success',
exportFailed: 'export_failed',
removeSuccess: 'remove_success',
removeFailed: 'remove_failed',
domainBindSuccess: 'domain_bind_success',
domainBindFailed: 'domain_bind_failed',
myDomainsSuccess: 'my_domains_success',
myDomainsFailed: 'my_domains_failed',
walletBalanceSuccess: 'wallet_balance_success',
walletBalanceFailed: 'wallet_balance_failed',
projectCreateSuccess: 'project_create_success',
projectCreateFailed: 'project_create_failed',
projectSaveSuccess: 'project_save_success',
projectSaveFailed: 'project_save_failed',
projectUpdateDbSuccess: 'project_update_db_success',
projectUpdateDbFailed: 'project_update_db_failed',
projectUpdateWorkerSuccess: 'project_update_worker_success',
projectUpdateWorkerFailed: 'project_update_worker_failed',
projectDeleteSuccess: 'project_delete_success',
projectDeleteFailed: 'project_delete_failed',
projectUpdateWebSuccess: 'project_update_web_success',
projectUpdateWebFailed: 'project_update_web_failed',
appKeyShownSuccess: 'appkey_shown_success',
appKeyShownFailed: 'appkey_shown_failed',
uploadHistoryViewed: 'upload_history_viewed',
uploadHistoryCleared: 'upload_history_cleared',
uploadHistoryFailed: 'upload_history_failed',
} as const;
export const TRACK_ACTIONS = {
init: 'init',
click: 'click',
view: 'view',
exposure: 'exposure',
submit: 'submit',
success: 'success',
fail: 'fail',
} as const;
export function resolveTrackAction(event: string): string {
switch (event) {
case TRACK_EVENTS.uploadHistoryViewed:
case TRACK_EVENTS.myDomainsSuccess:
case TRACK_EVENTS.walletBalanceSuccess:
case TRACK_EVENTS.appKeyShownSuccess:
return TRACK_ACTIONS.view;
case TRACK_EVENTS.uploadHistoryCleared:
return TRACK_ACTIONS.click;
case TRACK_EVENTS.uploadSuccess:
case TRACK_EVENTS.importSuccess:
case TRACK_EVENTS.exportSuccess:
case TRACK_EVENTS.removeSuccess:
case TRACK_EVENTS.domainBindSuccess:
case TRACK_EVENTS.cliLoginSuccess:
case TRACK_EVENTS.appKeySetSuccess:
case TRACK_EVENTS.logoutSuccess:
case TRACK_EVENTS.projectCreateSuccess:
case TRACK_EVENTS.projectSaveSuccess:
case TRACK_EVENTS.projectUpdateDbSuccess:
case TRACK_EVENTS.projectUpdateWorkerSuccess:
case TRACK_EVENTS.projectUpdateWebSuccess:
case TRACK_EVENTS.projectDeleteSuccess:
return TRACK_ACTIONS.success;
case TRACK_EVENTS.uploadFailed:
case TRACK_EVENTS.importFailed:
case TRACK_EVENTS.exportFailed:
case TRACK_EVENTS.removeFailed:
case TRACK_EVENTS.domainBindFailed:
case TRACK_EVENTS.cliLoginFailed:
case TRACK_EVENTS.appKeySetFailed:
case TRACK_EVENTS.logoutFailed:
case TRACK_EVENTS.myDomainsFailed:
case TRACK_EVENTS.walletBalanceFailed:
case TRACK_EVENTS.projectCreateFailed:
case TRACK_EVENTS.projectSaveFailed:
case TRACK_EVENTS.projectUpdateDbFailed:
case TRACK_EVENTS.projectUpdateWorkerFailed:
case TRACK_EVENTS.projectUpdateWebFailed:
case TRACK_EVENTS.projectDeleteFailed:
case TRACK_EVENTS.appKeyShownFailed:
case TRACK_EVENTS.uploadHistoryFailed:
return TRACK_ACTIONS.fail;
default:
return TRACK_ACTIONS.view;
}
}
+69
View File
@@ -0,0 +1,69 @@
import fs from 'fs';
import path from 'path';
const FILE_SIZE_LIMIT = parseInt(process.env.FILE_SIZE_LIMIT || '100', 10) * 1024 * 1024; // MB to bytes
const DIRECTORY_SIZE_LIMIT = parseInt(process.env.DIRECTORY_SIZE_LIMIT || '500', 10) * 1024 * 1024; // MB to bytes
interface FileSizeCheck {
size: number;
exceeds: boolean;
limit: number;
}
interface DirectorySizeCheck {
size: number;
limit: number;
exceeds: boolean;
}
function checkFileSizeLimit(filePath: string): FileSizeCheck {
const stats = fs.statSync(filePath);
return {
size: stats.size,
limit: FILE_SIZE_LIMIT,
exceeds: stats.size > FILE_SIZE_LIMIT
};
}
function checkDirectorySizeLimit(directoryPath: string): DirectorySizeCheck {
const totalSize = calculateDirectorySize(directoryPath);
return {
size: totalSize,
limit: DIRECTORY_SIZE_LIMIT,
exceeds: totalSize > DIRECTORY_SIZE_LIMIT
};
}
function calculateDirectorySize(directoryPath: string): number {
let totalSize = 0;
const files = fs.readdirSync(directoryPath);
for (const file of files) {
const filePath = path.join(directoryPath, file);
const stats = fs.statSync(filePath);
if (stats.isFile()) {
totalSize += stats.size;
} else if (stats.isDirectory()) {
totalSize += calculateDirectorySize(filePath);
}
}
return totalSize;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' bytes';
else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
else if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
else return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
export {
checkFileSizeLimit,
checkDirectorySizeLimit,
calculateDirectorySize,
formatSize,
FILE_SIZE_LIMIT,
DIRECTORY_SIZE_LIMIT
};
+20
View File
@@ -0,0 +1,20 @@
import uploadToIpfsSplit from './uploadToIpfsSplit';
import type { UploadAction } from './uploadToIpfsSplit';
/**
* @deprecated Legacy upload entry kept for compatibility.
* Use `uploadToIpfsSplit` directly for all new code.
*/
export default async function uploadToIpfs(
filePath: string,
options?: {
action?: UploadAction;
importAsCar?: boolean;
projectName?: string;
},
): Promise<{
contentHash: string;
shortUrl?: string;
} | null> {
return uploadToIpfsSplit(filePath, options);
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
import chalk from 'chalk';
type UrlTone = 'primary' | 'management';
export function printHighlightedUrl(
label: string,
url: string,
tone: UrlTone = 'primary',
): void {
const safeLabel = label.trim() || 'URL';
const safeUrl = url.trim();
const labelStyle = chalk.black.bgWhiteBright.bold;
const urlStyle =
tone === 'management'
? chalk.blueBright.bold.underline
: chalk.cyanBright.bold;
console.log('');
console.log(labelStyle(` ${safeLabel} `));
console.log(urlStyle(safeUrl));
console.log('');
}
+585
View File
@@ -0,0 +1,585 @@
import crypto from 'crypto';
import http from 'http';
import { URL } from 'url';
import chalk from 'chalk';
import { exec } from 'child_process';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { APP_CONFIG } from './config';
/* c8 ignore start -- Browser callback/login UI is covered by manual/e2e flows; token helpers below are unit-tested. */
/* Stryker disable all: Browser opening is OS integration; token helpers below are mutation-tested. */
// Cross-platform browser opener
function openBrowser(url: string): void {
const platform = process.platform;
let command: string;
if (platform === 'darwin') {
command = `open "${url}"`;
} else if (platform === 'win32') {
command = `start "" "${url}"`;
} else {
// linux
command = `xdg-open "${url}"`;
}
exec(command, (err) => {
if (err) {
console.log(chalk.yellow(`Unable to open browser automatically. Please visit manually: ${url}`));
}
});
}
/* Stryker restore all */
const CONFIG_DIR = path.join(os.homedir(), '.pinme');
const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
export interface AuthConfig {
address: string;
token: string;
expires_at?: number;
user_id?: string;
email?: string;
}
export interface LoginOptions {
apiBaseUrl?: string;
webBaseUrl?: string;
callbackPort?: number;
callbackPath?: string;
}
const DEFAULT_OPTIONS: Required<LoginOptions> = {
apiBaseUrl: APP_CONFIG.pinmeApiBase,
webBaseUrl: APP_CONFIG.pinmeWebUrl,
callbackPort: 34567,
callbackPath: '/cli/callback',
};
/* Stryker disable all: Interactive browser login and callback HTML are covered by manual/e2e flows. */
export class WebLoginManager {
private config: Required<LoginOptions>;
private server: http.Server | null = null;
private resolvePromise: ((value: string) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private loginToken: string = '';
constructor(options: LoginOptions = {}) {
this.config = { ...DEFAULT_OPTIONS, ...options };
}
async login(): Promise<AuthConfig> {
console.log(chalk.blue('Starting login flow...\n'));
// 1. Generate temporary login token
this.loginToken = this.generateLoginToken();
// 2. Start local server to wait for callback
console.log(chalk.blue('Starting local callback server...'));
await this.startCallbackServer();
try {
// 3. Build login URL and open browser
const loginUrl = this.buildLoginUrl();
console.log(chalk.blue('Opening browser...'));
console.log(chalk.white('If browser does not open automatically, please visit manually:'));
console.log(chalk.cyan(` ${loginUrl}\n`));
openBrowser(loginUrl);
console.log(chalk.yellow('Please complete login in browser...'));
console.log(chalk.gray('Browser will close automatically after successful login.\n'));
// 4. Wait for user to complete login
const authToken = await this.waitForCallback();
// 5. Parse token
const authConfig = this.parseAuthToken(authToken);
// 6. Save auth config
this.saveAuthConfig(authConfig);
console.log(chalk.green('\nLogin successful!'));
if (authConfig.email) {
console.log(chalk.green(`Welcome, ${authConfig.email}`));
}
console.log(chalk.gray(`Address: ${authConfig.address}`));
return authConfig;
} catch (error: any) {
console.error(chalk.red(`\nLogin failed: ${error.message}`));
throw error;
} finally {
this.closeServer();
}
}
private generateLoginToken(): string {
return crypto.randomBytes(32).toString('hex');
}
private async startCallbackServer(): Promise<void> {
return new Promise((resolve, reject) => {
this.server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || '', `http://localhost:${this.config.callbackPort}`);
if (url.pathname === this.config.callbackPath) {
const authToken = url.searchParams.get('token');
const error = url.searchParams.get('error');
const loginToken = url.searchParams.get('login_token');
if (error) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getErrorHtml(error));
if (this.rejectPromise) {
this.rejectPromise(new Error(error));
}
return;
}
if (!authToken) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getErrorHtml('Auth token not received'));
if (this.rejectPromise) {
this.rejectPromise(new Error('Auth token not received'));
}
return;
}
// Verify loginToken
if (loginToken !== this.loginToken) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getErrorHtml('Login token invalid or expired'));
if (this.rejectPromise) {
this.rejectPromise(new Error('Login token invalid or expired'));
}
return;
}
// Return success page
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getSuccessHtml());
// Pass token
if (this.resolvePromise) {
this.resolvePromise(authToken);
}
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
} catch (err: any) {
console.error('Callback error:', err);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
});
this.server.on('error', (err) => {
reject(err);
});
this.server.listen(this.config.callbackPort, '127.0.0.1', () => {
console.log(chalk.gray(`Local server: http://localhost:${this.config.callbackPort}`));
resolve();
});
// Set timeout (10 minutes)
setTimeout(() => {
if (this.rejectPromise) {
this.rejectPromise(new Error('Login timeout, please try again'));
}
this.closeServer();
}, 10 * 60 * 1000);
});
}
private buildLoginUrl(): string {
const callbackUrl = `http://localhost:${this.config.callbackPort}${this.config.callbackPath}`;
const url = `${this.config.webBaseUrl}/#/cli-login?` +
`login_token=${this.loginToken}&` +
`callback_url=${encodeURIComponent(callbackUrl)}`;
return url;
}
private waitForCallback(): Promise<string> {
return new Promise((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
}
private parseAuthToken(authToken: string): AuthConfig {
// authToken format: "<address>-<jwt>"
const firstDash = authToken.indexOf('-');
if (firstDash <= 0 || firstDash === authToken.length - 1) {
throw new Error('Invalid token format');
}
const address = authToken.slice(0, firstDash).trim();
const token = authToken.slice(firstDash + 1).trim();
if (!address || !token) {
throw new Error('Token parsing failed: address or token is empty');
}
return { address, token };
}
private saveAuthConfig(config: AuthConfig): void {
fs.ensureDirSync(CONFIG_DIR);
fs.writeJsonSync(AUTH_FILE, config, { spaces: 2 });
// Set file permissions (only current user can read)
fs.chmodSync(AUTH_FILE, 0o600);
}
private closeServer(): void {
if (this.server) {
this.server.close();
this.server = null;
}
}
// HTML templates
private getSuccessHtml(): string {
return `
<!DOCTYPE html>
<html>
<head>
<title>Login Success - PinMe</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #000;
overflow: hidden;
}
.bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at 20% 80%, rgba(120, 0, 255, 0.3) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(0, 200, 255, 0.3) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(255, 0, 150, 0.15) 0%, transparent 60%);
animation: bgPulse 6s ease-in-out infinite;
}
@keyframes bgPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
.grid {
position: fixed;
top: 0;
left: 0;
width: 200%;
height: 200%;
background-image:
linear-gradient(rgba(0, 200, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 200, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
transform: perspective(500px) rotateX(60deg) translateY(-50%) translateZ(-200px);
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: perspective(500px) rotateX(60deg) translateY(0) translateZ(-200px); }
100% { transform: perspective(500px) rotateX(60deg) translateY(50px) translateZ(-200px); }
}
.container {
position: relative;
z-index: 10;
background: linear-gradient(135deg, rgba(20, 20, 40, 0.9) 0%, rgba(10, 10, 30, 0.95) 100%);
padding: 3.5rem 4rem;
border-radius: 32px;
box-shadow:
0 0 60px rgba(0, 200, 255, 0.15),
0 25px 50px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 200, 255, 0.1);
text-align: center;
border: 1px solid rgba(0, 200, 255, 0.2);
max-width: 440px;
backdrop-filter: blur(30px);
}
.container::before {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border-radius: 32px;
background: linear-gradient(135deg, rgba(0, 200, 255, 0.5), rgba(255, 0, 150, 0.5), rgba(120, 0, 255, 0.5));
z-index: -1;
opacity: 0.5;
animation: borderGlow 3s ease-in-out infinite;
}
@keyframes borderGlow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
.success-icon {
font-size: 5rem;
margin-bottom: 1.5rem;
animation: bounceIn 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55);
filter: drop-shadow(0 0 20px rgba(0, 200, 255, 0.5));
}
@keyframes bounceIn {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
h1 {
color: #fff;
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 0.75rem 0;
background: linear-gradient(90deg, #fff, #00d4ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
p {
color: rgba(255, 255, 255, 0.6);
font-size: 1.1rem;
margin: 0 0 2rem 0;
line-height: 1.6;
}
.highlight { color: #00d4ff; font-weight: 600; }
.sparkle {
position: absolute;
width: 4px;
height: 4px;
background: #00d4ff;
border-radius: 50%;
animation: sparkle 2s ease-in-out infinite;
}
.sparkle:nth-child(1) { top: 20%; left: 10%; animation-delay: 0s; }
.sparkle:nth-child(2) { top: 30%; right: 15%; animation-delay: 0.5s; }
.sparkle:nth-child(3) { bottom: 25%; left: 20%; animation-delay: 1s; }
.sparkle:nth-child(4) { bottom: 35%; right: 10%; animation-delay: 1.5s; }
@keyframes sparkle {
0%, 100% { opacity: 0; transform: scale(0); }
50% { opacity: 1; transform: scale(1); }
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="grid"></div>
<div class="container">
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="success-icon">🎉</div>
<h1>Welcome to PinMe</h1>
<p>You are now logged in! <span class="highlight">🚀</span><br>Return to your terminal to continue.</p>
</div>
</body>
</html>`;
}
private getErrorHtml(error: string): string {
const encodedError = encodeURIComponent(error);
return `
<!DOCTYPE html>
<html>
<head>
<title>Login Failed - PinMe</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #000;
overflow: hidden;
}
.bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at 20% 80%, rgba(255, 50, 50, 0.2) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(255, 100, 50, 0.2) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(100, 0, 50, 0.15) 0%, transparent 60%);
animation: bgPulse 6s ease-in-out infinite;
}
@keyframes bgPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
.grid {
position: fixed;
top: 0;
left: 0;
width: 200%;
height: 200%;
background-image:
linear-gradient(rgba(255, 80, 80, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 80, 80, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
transform: perspective(500px) rotateX(60deg) translateY(-50%) translateZ(-200px);
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: perspective(500px) rotateX(60deg) translateY(0) translateZ(-200px); }
100% { transform: perspective(500px) rotateX(60deg) translateY(50px) translateZ(-200px); }
}
.container {
position: relative;
z-index: 10;
background: linear-gradient(135deg, rgba(40, 20, 20, 0.9) 0%, rgba(30, 10, 10, 0.95) 100%);
padding: 3.5rem 4rem;
border-radius: 32px;
box-shadow:
0 0 60px rgba(255, 50, 50, 0.15),
0 25px 50px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(255, 50, 50, 0.1);
text-align: center;
border: 1px solid rgba(255, 50, 50, 0.2);
max-width: 440px;
backdrop-filter: blur(30px);
}
.container::before {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border-radius: 32px;
background: linear-gradient(135deg, rgba(255, 50, 50, 0.5), rgba(255, 150, 50, 0.5), rgba(150, 0, 50, 0.5));
z-index: -1;
opacity: 0.5;
animation: borderGlow 3s ease-in-out infinite;
}
@keyframes borderGlow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
.error-icon {
font-size: 5rem;
margin-bottom: 1.5rem;
animation: shake 0.5s ease-in-out;
filter: drop-shadow(0 0 20px rgba(255, 50, 50, 0.5));
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-10px) rotate(-5deg); }
40% { transform: translateX(10px) rotate(5deg); }
60% { transform: translateX(-10px) rotate(-5deg); }
80% { transform: translateX(10px) rotate(5deg); }
}
h1 {
color: #fff;
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 0.75rem 0;
background: linear-gradient(90deg, #fff, #ff5050);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.error {
color: #ff6b6b;
font-size: 1rem;
margin: 0 0 2rem 0;
padding: 1.25rem;
background: rgba(255, 50, 50, 0.1);
border-radius: 16px;
border: 1px solid rgba(255, 50, 50, 0.2);
font-weight: 500;
box-shadow: 0 0 20px rgba(255, 50, 50, 0.1);
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="grid"></div>
<div class="container">
<div class="error-icon">😵</div>
<h1>Oops!</h1>
<div class="error">${error}</div>
</div>
</body>
</html>`;
}
}
// Export singleton
export const webLoginManager = new WebLoginManager();
/* Stryker restore all */
/* c8 ignore stop */
// Legacy interface
export function setAuthToken(combined: string): AuthConfig {
const firstDash = combined.indexOf('-');
if (firstDash <= 0 || firstDash === combined.length - 1) {
throw new Error('Invalid token format. Expected "<address>-<jwt>".');
}
const address = combined.slice(0, firstDash).trim();
const token = combined.slice(firstDash + 1).trim();
if (!address || !token) {
throw new Error('Invalid token content. Address or token is empty.');
}
const config: AuthConfig = { address, token };
fs.ensureDirSync(CONFIG_DIR);
fs.writeJsonSync(AUTH_FILE, config, { spaces: 2 });
return config;
}
export function getAuthConfig(): AuthConfig | null {
try {
if (!fs.existsSync(AUTH_FILE)) return null;
const data = fs.readJsonSync(AUTH_FILE) as AuthConfig;
if (!data?.address || !data?.token) return null;
return data;
} catch {
return null;
}
}
export function clearAuthToken(): void {
try {
if (fs.existsSync(AUTH_FILE)) {
fs.removeSync(AUTH_FILE);
}
} catch (error) {
console.error(`Failed to clear auth token: ${error}`);
}
}
export function getAuthHeaders(): Record<string, string> {
const conf = getAuthConfig();
if (!conf) {
throw new Error('Auth not set. Run: pinme login');
}
return {
'token-address': conf.address,
'authentication-tokens': conf.token,
};
}
export async function login(): Promise<AuthConfig> {
return webLoginManager.login();
}
export async function logout(): Promise<void> {
clearAuthToken();
console.log(chalk.green('Logged out successfully'));
}
+87
View File
@@ -0,0 +1,87 @@
import { createConfigError } from './cliError';
interface WorkerMetadataBinding {
name?: string;
text?: unknown;
}
interface WorkerMetadata {
project_name?: string;
bindings?: WorkerMetadataBinding[];
}
function parseWorkerMetadata(metadataContent: string): WorkerMetadata {
try {
return JSON.parse(metadataContent);
} catch (error) {
throw createConfigError('Worker metadata must be valid JSON.', [
'The `/create_worker` API should return JSON metadata for backend deployment.',
'Retry `pinme create`.',
]);
}
}
function isRealBindingValue(value: unknown): value is string {
if (typeof value !== 'string') {
return false;
}
const trimmed = value.trim();
return Boolean(trimmed)
&& trimmed !== 'xxx'
&& trimmed !== 'project_name'
&& !trimmed.startsWith('__PINME_');
}
function findBinding(metadata: WorkerMetadata, name: string): WorkerMetadataBinding | undefined {
return metadata.bindings?.find((binding) => binding.name === name);
}
export function validateWorkerMetadataForCreate(
metadataContent: string,
projectName: string,
): void {
const metadata = parseWorkerMetadata(metadataContent);
const apiKeyBinding = findBinding(metadata, 'API_KEY');
const projectNameBinding = findBinding(metadata, 'PROJECT_NAME');
if (!isRealBindingValue(apiKeyBinding?.text)) {
throw createConfigError('Worker metadata is missing a real API_KEY binding.', [
'The template metadata contains placeholder values and cannot be deployed as-is.',
'Retry `pinme create` so Pinme can fetch fresh worker metadata from `/create_worker`.',
]);
}
if (projectNameBinding?.text !== projectName) {
throw createConfigError('Worker metadata is missing a matching PROJECT_NAME binding.', [
`Expected PROJECT_NAME binding text to be \`${projectName}\`.`,
'Retry `pinme create` so Pinme can fetch fresh worker metadata from `/create_worker`.',
]);
}
if (metadata.project_name && metadata.project_name !== projectName) {
throw createConfigError('Worker metadata project_name does not match the created project.', [
`Expected metadata project_name to be \`${projectName}\`.`,
`Received metadata project_name \`${metadata.project_name}\`.`,
]);
}
}
export function getValidatedWorkerMetadataContent(
metadata: unknown,
projectName: string,
): string {
if (!metadata) {
throw createConfigError('Worker metadata is missing from project creation response.', [
'The `/create_worker` API should return backend metadata before the prebuilt worker is deployed.',
'Retry `pinme create`.',
]);
}
const metadataContent = typeof metadata === 'string'
? metadata
: JSON.stringify(metadata, null, 2);
validateWorkerMetadataForCreate(metadataContent, projectName);
return metadataContent;
}
+52
View File
@@ -0,0 +1,52 @@
import chalk from 'chalk';
import { printCliError, printRechargeUrl } from './utils/cliError';
import { getWalletRechargeUrl } from './utils/config';
import { getWalletBalance } from './utils/pinmeApi';
import { getAuthConfig } from './utils/webLogin';
import tracker, { getTrackErrorReason } from './utils/tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './utils/trackerEvents';
export default async function walletBalanceCmd(): Promise<void> {
try {
const auth = getAuthConfig();
if (!auth) {
console.log(chalk.yellow('Please login first. Run: pinme set-appkey <AppKey>'));
return;
}
const result = await getWalletBalance(auth.address, auth.token);
const balance = Number(result.data?.wallet_balance_usd ?? 0);
if (!Number.isFinite(balance)) {
void tracker.trackEvent(TRACK_EVENTS.walletBalanceFailed, TRACK_PAGES.wallet, {
a: resolveTrackAction(TRACK_EVENTS.walletBalanceFailed),
reason: 'invalid_balance_value',
});
console.log(chalk.red('Failed to parse wallet balance.'));
return;
}
void tracker.trackEvent(TRACK_EVENTS.walletBalanceSuccess, TRACK_PAGES.wallet, {
a: resolveTrackAction(TRACK_EVENTS.walletBalanceSuccess),
has_balance: balance > 0,
balance_usd: balance.toFixed(2),
});
console.log(chalk.cyan('Wallet balance:'));
console.log(chalk.green(` USD: $${balance.toFixed(2)}`));
if (balance <= 0) {
console.log(chalk.red('Insufficient wallet balance. Please recharge your wallet first.'));
printRechargeUrl(getWalletRechargeUrl());
}
} catch (e: any) {
void tracker.trackEvent(TRACK_EVENTS.walletBalanceFailed, TRACK_PAGES.wallet, {
a: resolveTrackAction(TRACK_EVENTS.walletBalanceFailed),
reason: getTrackErrorReason(e),
});
printCliError(e, 'Failed to fetch wallet balance.');
}
}
+28
View File
@@ -0,0 +1,28 @@
const VALID_DEFINE_ENV_KEY = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
function stringifyEnvDefine(value) {
return value === undefined ? 'undefined' : JSON.stringify(value);
}
function createDefineMap(env = process.env) {
const define = {};
for (const key in env) {
// Skip env vars with invalid identifier characters for esbuild defines.
if (VALID_DEFINE_ENV_KEY.test(key)) {
define[`process.env.${key}`] = stringifyEnvDefine(env[key]);
}
}
define['process.env.IPFS_PREVIEW_URL'] = stringifyEnvDefine(
env.IPFS_PREVIEW_URL,
);
define['process.env.SECRET_KEY'] = stringifyEnvDefine(env.SECRET_KEY);
return define;
}
module.exports = {
createDefineMap,
stringifyEnvDefine,
};
+18
View File
@@ -0,0 +1,18 @@
require('dotenv').config();
const esbuild = require('esbuild');
const { createDefineMap } = require('./build-env');
const define = createDefineMap();
esbuild.build({
entryPoints: ['bin/index.ts'],
outfile: 'dist/index.js',
bundle: true,
platform: 'node',
target: 'node14',
format: 'cjs',
external: Object.keys(require('./package.json').dependencies || {}).filter(dep => dep !== 'axios'),
banner: { js: '#!/usr/bin/env node' },
logLevel: 'info',
define,
}).catch(() => process.exit(1));
View File
+85
View File
@@ -0,0 +1,85 @@
# PinMe
[PinMe](https://pinme.eth.limo/) is a simple and easy-to-use command-line tool for uploading files and directories to the [IPFS](https://ipfs.tech/) network.
Website: [https://pinme.eth.limo/](https://pinme.eth.limo/)
## Features
- Simple and intuitive command-line interface
- Fast uploading of files and directories to IPFS
- Automatic generation and display of access links
- Built-in Pinning service integration for persistent content
- Support for various configuration options
## Installing PinMe
```bash
npm install -g pinme
# or
yarn global add pinme
```
# PinMe Documentation Project
This is the official documentation project for the PinMe tool, containing detailed user guides, API references, and best practices.
## Documentation Structure
```
./
├── guide/ # User guide directory
├── api/ # API reference documentation
├── examples/ # Example code and use cases
├── images/ # Documentation image resources
└── index.md # Documentation homepage
```
## Local Preview
You can preview the documentation using any static web server:
```bash
# Using Python's simple HTTP server
python -m http.server
# Or using Node.js http-server
npx http-server ./
```
## Deploy Documentation to IPFS
```bash
# Upload the entire documentation directory to IPFS
pinme upload ./
```
## After Deployment
After successful deployment, PinMe will output the CID and access links. You can access the documentation via:
- IPFS Gateway: `https://ipfs.io/ipfs/<your-CID>`
## Updating Documentation
1. Modify the relevant Markdown files
2. Redeploy to IPFS
3. Update DNSLink to point to the new CID (if using a custom domain)
## Contributing to Documentation
We welcome community contributions to the documentation:
1. Fork this repository
2. Create your feature branch (`git checkout -b feature/amazing-doc`)
3. Commit your changes (`git commit -m 'Add some amazing doc'`)
4. Push to the branch (`git push origin feature/amazing-doc`)
5. Open a Pull Request
## License
MIT
```bash
pinme upload ./
```
+2
View File
@@ -0,0 +1,2 @@
* [Home](/)
* [Contact](./contact/index.md)
+5
View File
@@ -0,0 +1,5 @@
* [Home](./)
* [Quick Start](./quickstart)
* Guide
* [Install](./guide/installation)
* [Config](./guide/config)
+3
View File
@@ -0,0 +1,3 @@
* [Contact](./contact/index)
* [test](./contact/test)
+3
View File
@@ -0,0 +1,3 @@
# Contact
[Glitter](https://glitterprotocol.io/)
+3
View File
@@ -0,0 +1,3 @@
# test
test page
+2
View File
@@ -0,0 +1,2 @@
# config
pinme -help
+5
View File
@@ -0,0 +1,5 @@
# install
```bash
npm -g install pinme
```
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="description" content="Description">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
</head>
<body>
<div id="app"></div>
<script>
window.$docsify = {
name: 'PINME',
repo: 'https://github.com/glitternetwork/pinme',
themeColor: '#2ecc71',
loadNavbar: '_navbar.md',
loadSidebar: '_sidebar.md',
}
</script>
<!-- Docsify v4 -->
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
# Quick Start
quick start
View File
+82
View File
@@ -0,0 +1,82 @@
# PinMe
[PinMe](https://pinme.eth.limo/) is a simple and easy-to-use command-line tool for uploading files and directories to the [IPFS](https://ipfs.tech/) network.
Website: [https://pinme.eth.limo/](https://pinme.eth.limo/)
## Features
- Simple and intuitive command-line interface
- Fast uploading of files and directories to IPFS
- Automatic generation and display of access links
- Built-in Pinning service integration for persistent content
- Support for various configuration options
## Installing PinMe
```bash
npm install -g pinme
# or
yarn global add pinme
```
# PinMe Deploy Blog
This is a Hugo-based static blog project that can be easily deployed to the IPFS network.
## Project Structure
```
./
├── content/ # Blog content directory
├── themes/ # Hugo themes directory
├── layouts/ # Custom layouts directory
├── static/ # Static assets directory
└── public/ # Build output directory
```
## Prerequisites
- [Hugo](https://gohugo.io/) (Static site generator) installed
- PinMe tool installed
## Local Preview
```bash
# Start Hugo server for local preview
hugo server -D
```
## Build and Deploy
```bash
# Build the static site
hugo
# Upload the built site to IPFS
pinme upload ./public
```
## After Deployment
After successful deployment, PinMe will output the CID and access links. You can access your blog via:
- IPFS Gateway: `https://ipfs.io/ipfs/<your-CID>`
- Or set up a custom domain
## Custom Domain
To access your blog with a custom domain, refer to the DNSLink setup guide in the PinMe documentation.
## Contributing
Issues and Pull Requests are welcome to help improve this project.
## License
MIT
```bash
hugo build
pinme upload ./public
```
+5
View File
@@ -0,0 +1,5 @@
+++
date = '{{ .Date }}'
draft = true
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
+++
+9
View File
@@ -0,0 +1,9 @@
languageCode = "en-us"
title = "PINME Blog"
theme = "PaperMod"
baseURL = "./"
[params]
relativeURLs = true
canonifyURLs = false
@@ -0,0 +1,6 @@
+++
date = '2025-04-24T18:23:16+08:00'
draft = false
title = 'Ecommerce Intro'
+++
# Hello World
+9
View File
@@ -0,0 +1,9 @@
languageCode = "en-us"
title = "PINME Blog"
theme = "PaperMod"
baseURL = "./"
[params]
relativeURLs = true
canonifyURLs = false
+187
View File
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="en" dir="auto">
<head><meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="index, follow">
<title>404 Page not found | PINME Blog</title>
<meta name="keywords" content="">
<meta name="description" content="">
<meta name="author" content="">
<link rel="canonical" href="./404.html">
<link crossorigin="anonymous" href="./assets/css/stylesheet.f49d66caae9ea0fd43f21f29e71a8d3e284517ed770f2aa86fa012953ad3c9ef.css" integrity="sha256-9J1myq6eoP1D8h8p5xqNPihFF&#43;13Dyqob6ASlTrTye8=" rel="preload stylesheet" as="style">
<link rel="icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="mask-icon" href="safari-pinned-tab.svg">
<meta name="theme-color" content="#2e2e33">
<meta name="msapplication-TileColor" content="#2e2e33">
<link rel="alternate" hreflang="en" href="./404.html">
<noscript>
<style>
#theme-toggle,
.top-link {
display: none;
}
</style>
<style>
@media (prefers-color-scheme: dark) {
:root {
--theme: rgb(29, 30, 32);
--entry: rgb(46, 46, 51);
--primary: rgb(218, 218, 219);
--secondary: rgb(155, 156, 157);
--tertiary: rgb(65, 66, 68);
--content: rgb(196, 196, 197);
--code-block-bg: rgb(46, 46, 51);
--code-bg: rgb(55, 56, 62);
--border: rgb(51, 51, 51);
}
.list {
background: var(--theme);
}
.list:not(.dark)::-webkit-scrollbar-track {
background: 0 0;
}
.list:not(.dark)::-webkit-scrollbar-thumb {
border-color: var(--theme);
}
}
</style>
</noscript><meta property="og:url" content="./404.html">
<meta property="og:site_name" content="PINME Blog">
<meta property="og:title" content="404 Page not found">
<meta property="og:locale" content="zh-cn">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="404 Page not found">
<meta name="twitter:description" content="">
</head>
<body class="list" id="top">
<script>
if (localStorage.getItem("pref-theme") === "dark") {
document.body.classList.add('dark');
} else if (localStorage.getItem("pref-theme") === "light") {
document.body.classList.remove('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
</script>
<header class="header">
<nav class="nav">
<div class="logo">
<a href="./" accesskey="h" title="PINME Blog (Alt + H)">PINME Blog</a>
<div class="logo-switches">
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg id="sun" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</div>
<ul id="menu">
</ul>
</nav>
</header>
<main class="main">
<div class="not-found">404</div>
</main>
<footer class="footer">
<span>&copy; 2025 <a href="./">PINME Blog</a></span> ·
<span>
Powered by
<a href="https://gohugo.io/" rel="noopener noreferrer" target="_blank">Hugo</a> &
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
</span>
</footer>
<a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
<path d="M12 6H0l6-6z" />
</svg>
</a>
<script>
let menu = document.getElementById('menu')
if (menu) {
menu.scrollLeft = localStorage.getItem("menu-scroll-position");
menu.onscroll = function () {
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
}
}
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
var id = this.getAttribute("href").substr(1);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView({
behavior: "smooth"
});
} else {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView();
}
if (id === "top") {
history.replaceState(null, null, " ");
} else {
history.pushState(null, null, `#${id}`);
}
});
});
</script>
<script>
var mybutton = document.getElementById("top-link");
window.onscroll = function () {
if (document.body.scrollTop > 800 || document.documentElement.scrollTop > 800) {
mybutton.style.visibility = "visible";
mybutton.style.opacity = "1";
} else {
mybutton.style.visibility = "hidden";
mybutton.style.opacity = "0";
}
};
</script>
<script>
document.getElementById("theme-toggle").addEventListener("click", () => {
if (document.body.className.includes("dark")) {
document.body.classList.remove('dark');
localStorage.setItem("pref-theme", 'light');
} else {
document.body.classList.add('dark');
localStorage.setItem("pref-theme", 'dark');
}
})
</script>
</body>
</html>
@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="en" dir="auto">
<head><meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="index, follow">
<title>Categories | PINME Blog</title>
<meta name="keywords" content="">
<meta name="description" content="">
<meta name="author" content="">
<link rel="canonical" href="./categories/">
<link crossorigin="anonymous" href="./assets/css/stylesheet.f49d66caae9ea0fd43f21f29e71a8d3e284517ed770f2aa86fa012953ad3c9ef.css" integrity="sha256-9J1myq6eoP1D8h8p5xqNPihFF&#43;13Dyqob6ASlTrTye8=" rel="preload stylesheet" as="style">
<link rel="icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="mask-icon" href="safari-pinned-tab.svg">
<meta name="theme-color" content="#2e2e33">
<meta name="msapplication-TileColor" content="#2e2e33">
<link rel="alternate" type="application/rss+xml" href="./categories/index.xml">
<link rel="alternate" hreflang="en" href="./categories/">
<noscript>
<style>
#theme-toggle,
.top-link {
display: none;
}
</style>
<style>
@media (prefers-color-scheme: dark) {
:root {
--theme: rgb(29, 30, 32);
--entry: rgb(46, 46, 51);
--primary: rgb(218, 218, 219);
--secondary: rgb(155, 156, 157);
--tertiary: rgb(65, 66, 68);
--content: rgb(196, 196, 197);
--code-block-bg: rgb(46, 46, 51);
--code-bg: rgb(55, 56, 62);
--border: rgb(51, 51, 51);
}
.list {
background: var(--theme);
}
.list:not(.dark)::-webkit-scrollbar-track {
background: 0 0;
}
.list:not(.dark)::-webkit-scrollbar-thumb {
border-color: var(--theme);
}
}
</style>
</noscript><meta property="og:url" content="./categories/">
<meta property="og:site_name" content="PINME Blog">
<meta property="og:title" content="Categories">
<meta property="og:locale" content="zh-cn">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Categories">
<meta name="twitter:description" content="">
</head>
<body class="list" id="top">
<script>
if (localStorage.getItem("pref-theme") === "dark") {
document.body.classList.add('dark');
} else if (localStorage.getItem("pref-theme") === "light") {
document.body.classList.remove('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
</script>
<header class="header">
<nav class="nav">
<div class="logo">
<a href="./" accesskey="h" title="PINME Blog (Alt + H)">PINME Blog</a>
<div class="logo-switches">
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg id="sun" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</div>
<ul id="menu">
</ul>
</nav>
</header>
<main class="main">
<header class="page-header">
<h1>Categories</h1>
</header>
<ul class="terms-tags">
</ul>
</main>
<footer class="footer">
<span>&copy; 2025 <a href="./">PINME Blog</a></span> ·
<span>
Powered by
<a href="https://gohugo.io/" rel="noopener noreferrer" target="_blank">Hugo</a> &
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
</span>
</footer>
<a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
<path d="M12 6H0l6-6z" />
</svg>
</a>
<script>
let menu = document.getElementById('menu')
if (menu) {
menu.scrollLeft = localStorage.getItem("menu-scroll-position");
menu.onscroll = function () {
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
}
}
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
var id = this.getAttribute("href").substr(1);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView({
behavior: "smooth"
});
} else {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView();
}
if (id === "top") {
history.replaceState(null, null, " ");
} else {
history.pushState(null, null, `#${id}`);
}
});
});
</script>
<script>
var mybutton = document.getElementById("top-link");
window.onscroll = function () {
if (document.body.scrollTop > 800 || document.documentElement.scrollTop > 800) {
mybutton.style.visibility = "visible";
mybutton.style.opacity = "1";
} else {
mybutton.style.visibility = "hidden";
mybutton.style.opacity = "0";
}
};
</script>
<script>
document.getElementById("theme-toggle").addEventListener("click", () => {
if (document.body.className.includes("dark")) {
document.body.classList.remove('dark');
localStorage.setItem("pref-theme", 'light');
} else {
document.body.classList.add('dark');
localStorage.setItem("pref-theme", 'dark');
}
})
</script>
</body>
</html>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Categories on PINME Blog</title>
<link>./categories/</link>
<description>Recent content in Categories on PINME Blog</description>
<generator>Hugo -- 0.146.7</generator>
<language>zh-cn</language>
<atom:link href="./categories/index.xml" rel="self" type="application/rss+xml" />
</channel>
</rss>
+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="en" dir="auto">
<head>
<meta name="generator" content="Hugo 0.146.7"><meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="index, follow">
<title>PINME Blog</title>
<meta name="description" content="">
<meta name="author" content="">
<link rel="canonical" href="./">
<link crossorigin="anonymous" href="./assets/css/stylesheet.f49d66caae9ea0fd43f21f29e71a8d3e284517ed770f2aa86fa012953ad3c9ef.css" integrity="sha256-9J1myq6eoP1D8h8p5xqNPihFF&#43;13Dyqob6ASlTrTye8=" rel="preload stylesheet" as="style">
<link rel="icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="mask-icon" href="safari-pinned-tab.svg">
<meta name="theme-color" content="#2e2e33">
<meta name="msapplication-TileColor" content="#2e2e33">
<link rel="alternate" type="application/rss+xml" href="./index.xml">
<link rel="alternate" hreflang="en" href="./">
<noscript>
<style>
#theme-toggle,
.top-link {
display: none;
}
</style>
<style>
@media (prefers-color-scheme: dark) {
:root {
--theme: rgb(29, 30, 32);
--entry: rgb(46, 46, 51);
--primary: rgb(218, 218, 219);
--secondary: rgb(155, 156, 157);
--tertiary: rgb(65, 66, 68);
--content: rgb(196, 196, 197);
--code-block-bg: rgb(46, 46, 51);
--code-bg: rgb(55, 56, 62);
--border: rgb(51, 51, 51);
}
.list {
background: var(--theme);
}
.list:not(.dark)::-webkit-scrollbar-track {
background: 0 0;
}
.list:not(.dark)::-webkit-scrollbar-thumb {
border-color: var(--theme);
}
}
</style>
</noscript><meta property="og:url" content="./">
<meta property="og:site_name" content="PINME Blog">
<meta property="og:title" content="PINME Blog">
<meta property="og:locale" content="zh-cn">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="PINME Blog">
<meta name="twitter:description" content="">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "PINME Blog",
"url": "./",
"description": "",
"logo": "favicon.ico",
"sameAs": [
]
}
</script>
</head>
<body class="list" id="top">
<script>
if (localStorage.getItem("pref-theme") === "dark") {
document.body.classList.add('dark');
} else if (localStorage.getItem("pref-theme") === "light") {
document.body.classList.remove('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
</script>
<header class="header">
<nav class="nav">
<div class="logo">
<a href="./" accesskey="h" title="PINME Blog (Alt + H)">PINME Blog</a>
<div class="logo-switches">
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg id="sun" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</div>
<ul id="menu">
</ul>
</nav>
</header>
<main class="main">
<article class="first-entry">
<header class="entry-header">
<h2 class="entry-hint-parent">Ecommerce Intro
</h2>
</header>
<div class="entry-content">
<p>Hello World</p>
</div>
<footer class="entry-footer"><span title='2025-04-24 18:23:16 +0800 CST'>April 24, 2025</span></footer>
<a class="entry-link" aria-label="post link to Ecommerce Intro" href="./posts/ecommerce-intro/"></a>
</article>
</main>
<footer class="footer">
<span>&copy; 2025 <a href="./">PINME Blog</a></span> ·
<span>
Powered by
<a href="https://gohugo.io/" rel="noopener noreferrer" target="_blank">Hugo</a> &
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
</span>
</footer>
<a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
<path d="M12 6H0l6-6z" />
</svg>
</a>
<script>
let menu = document.getElementById('menu')
if (menu) {
menu.scrollLeft = localStorage.getItem("menu-scroll-position");
menu.onscroll = function () {
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
}
}
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
var id = this.getAttribute("href").substr(1);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView({
behavior: "smooth"
});
} else {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView();
}
if (id === "top") {
history.replaceState(null, null, " ");
} else {
history.pushState(null, null, `#${id}`);
}
});
});
</script>
<script>
var mybutton = document.getElementById("top-link");
window.onscroll = function () {
if (document.body.scrollTop > 800 || document.documentElement.scrollTop > 800) {
mybutton.style.visibility = "visible";
mybutton.style.opacity = "1";
} else {
mybutton.style.visibility = "hidden";
mybutton.style.opacity = "0";
}
};
</script>
<script>
document.getElementById("theme-toggle").addEventListener("click", () => {
if (document.body.className.includes("dark")) {
document.body.classList.remove('dark');
localStorage.setItem("pref-theme", 'light');
} else {
document.body.classList.add('dark');
localStorage.setItem("pref-theme", 'dark');
}
})
</script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>PINME Blog</title>
<link>./</link>
<description>Recent content on PINME Blog</description>
<generator>Hugo -- 0.146.7</generator>
<language>zh-cn</language>
<lastBuildDate>Thu, 24 Apr 2025 18:23:16 +0800</lastBuildDate>
<atom:link href="./index.xml" rel="self" type="application/rss+xml" />
<item>
<title>Ecommerce Intro</title>
<link>./posts/ecommerce-intro/</link>
<pubDate>Thu, 24 Apr 2025 18:23:16 +0800</pubDate>
<guid>./posts/ecommerce-intro/</guid>
<description>&lt;h1 id=&#34;hello-world&#34;&gt;Hello World&lt;/h1&gt;</description>
</item>
</channel>
</rss>
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<title>./</title>
<link rel="canonical" href="./">
<meta name="robots" content="noindex">
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=./">
</head>
</html>
@@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="en" dir="auto">
<head><meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="index, follow">
<title>Ecommerce Intro | PINME Blog</title>
<meta name="keywords" content="">
<meta name="description" content="Hello World">
<meta name="author" content="">
<link rel="canonical" href="./posts/ecommerce-intro/">
<link crossorigin="anonymous" href="./assets/css/stylesheet.f49d66caae9ea0fd43f21f29e71a8d3e284517ed770f2aa86fa012953ad3c9ef.css" integrity="sha256-9J1myq6eoP1D8h8p5xqNPihFF&#43;13Dyqob6ASlTrTye8=" rel="preload stylesheet" as="style">
<link rel="icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="mask-icon" href="safari-pinned-tab.svg">
<meta name="theme-color" content="#2e2e33">
<meta name="msapplication-TileColor" content="#2e2e33">
<link rel="alternate" hreflang="en" href="./posts/ecommerce-intro/">
<noscript>
<style>
#theme-toggle,
.top-link {
display: none;
}
</style>
<style>
@media (prefers-color-scheme: dark) {
:root {
--theme: rgb(29, 30, 32);
--entry: rgb(46, 46, 51);
--primary: rgb(218, 218, 219);
--secondary: rgb(155, 156, 157);
--tertiary: rgb(65, 66, 68);
--content: rgb(196, 196, 197);
--code-block-bg: rgb(46, 46, 51);
--code-bg: rgb(55, 56, 62);
--border: rgb(51, 51, 51);
}
.list {
background: var(--theme);
}
.list:not(.dark)::-webkit-scrollbar-track {
background: 0 0;
}
.list:not(.dark)::-webkit-scrollbar-thumb {
border-color: var(--theme);
}
}
</style>
</noscript><meta property="og:url" content="./posts/ecommerce-intro/">
<meta property="og:site_name" content="PINME Blog">
<meta property="og:title" content="Ecommerce Intro">
<meta property="og:description" content="Hello World">
<meta property="og:locale" content="zh-cn">
<meta property="og:type" content="article">
<meta property="article:section" content="posts">
<meta property="article:published_time" content="2025-04-24T18:23:16+08:00">
<meta property="article:modified_time" content="2025-04-24T18:23:16+08:00">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Ecommerce Intro">
<meta name="twitter:description" content="Hello World">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1 ,
"name": "Posts",
"item": "./posts/"
},
{
"@type": "ListItem",
"position": 2 ,
"name": "Ecommerce Intro",
"item": "./posts/ecommerce-intro/"
}
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Ecommerce Intro",
"name": "Ecommerce Intro",
"description": "Hello World",
"keywords": [
],
"articleBody": "Hello World ",
"wordCount" : "2",
"inLanguage": "en",
"datePublished": "2025-04-24T18:23:16+08:00",
"dateModified": "2025-04-24T18:23:16+08:00",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "./posts/ecommerce-intro/"
},
"publisher": {
"@type": "Organization",
"name": "PINME Blog",
"logo": {
"@type": "ImageObject",
"url": "favicon.ico"
}
}
}
</script>
</head>
<body class="" id="top">
<script>
if (localStorage.getItem("pref-theme") === "dark") {
document.body.classList.add('dark');
} else if (localStorage.getItem("pref-theme") === "light") {
document.body.classList.remove('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
</script>
<header class="header">
<nav class="nav">
<div class="logo">
<a href="./" accesskey="h" title="PINME Blog (Alt + H)">PINME Blog</a>
<div class="logo-switches">
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg id="sun" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</div>
<ul id="menu">
</ul>
</nav>
</header>
<main class="main">
<article class="post-single">
<header class="post-header">
<h1 class="post-title entry-hint-parent">
Ecommerce Intro
</h1>
<div class="post-meta"><span title='2025-04-24 18:23:16 +0800 CST'>April 24, 2025</span>
</div>
</header>
<div class="post-content"><h1 id="hello-world">Hello World<a hidden class="anchor" aria-hidden="true" href="#hello-world">#</a></h1>
</div>
<footer class="post-footer">
<ul class="post-tags">
</ul>
</footer>
</article>
</main>
<footer class="footer">
<span>&copy; 2025 <a href="./">PINME Blog</a></span> ·
<span>
Powered by
<a href="https://gohugo.io/" rel="noopener noreferrer" target="_blank">Hugo</a> &
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
</span>
</footer>
<a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
<path d="M12 6H0l6-6z" />
</svg>
</a>
<script>
let menu = document.getElementById('menu')
if (menu) {
menu.scrollLeft = localStorage.getItem("menu-scroll-position");
menu.onscroll = function () {
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
}
}
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
var id = this.getAttribute("href").substr(1);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView({
behavior: "smooth"
});
} else {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView();
}
if (id === "top") {
history.replaceState(null, null, " ");
} else {
history.pushState(null, null, `#${id}`);
}
});
});
</script>
<script>
var mybutton = document.getElementById("top-link");
window.onscroll = function () {
if (document.body.scrollTop > 800 || document.documentElement.scrollTop > 800) {
mybutton.style.visibility = "visible";
mybutton.style.opacity = "1";
} else {
mybutton.style.visibility = "hidden";
mybutton.style.opacity = "0";
}
};
</script>
<script>
document.getElementById("theme-toggle").addEventListener("click", () => {
if (document.body.className.includes("dark")) {
document.body.classList.remove('dark');
localStorage.setItem("pref-theme", 'light');
} else {
document.body.classList.add('dark');
localStorage.setItem("pref-theme", 'dark');
}
})
</script>
</body>
</html>
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="en" dir="auto">
<head><meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="index, follow">
<title>Posts | PINME Blog</title>
<meta name="keywords" content="">
<meta name="description" content="Posts - PINME Blog">
<meta name="author" content="">
<link rel="canonical" href="./posts/">
<link crossorigin="anonymous" href="./assets/css/stylesheet.f49d66caae9ea0fd43f21f29e71a8d3e284517ed770f2aa86fa012953ad3c9ef.css" integrity="sha256-9J1myq6eoP1D8h8p5xqNPihFF&#43;13Dyqob6ASlTrTye8=" rel="preload stylesheet" as="style">
<link rel="icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="mask-icon" href="safari-pinned-tab.svg">
<meta name="theme-color" content="#2e2e33">
<meta name="msapplication-TileColor" content="#2e2e33">
<link rel="alternate" type="application/rss+xml" href="./posts/index.xml">
<link rel="alternate" hreflang="en" href="./posts/">
<noscript>
<style>
#theme-toggle,
.top-link {
display: none;
}
</style>
<style>
@media (prefers-color-scheme: dark) {
:root {
--theme: rgb(29, 30, 32);
--entry: rgb(46, 46, 51);
--primary: rgb(218, 218, 219);
--secondary: rgb(155, 156, 157);
--tertiary: rgb(65, 66, 68);
--content: rgb(196, 196, 197);
--code-block-bg: rgb(46, 46, 51);
--code-bg: rgb(55, 56, 62);
--border: rgb(51, 51, 51);
}
.list {
background: var(--theme);
}
.list:not(.dark)::-webkit-scrollbar-track {
background: 0 0;
}
.list:not(.dark)::-webkit-scrollbar-thumb {
border-color: var(--theme);
}
}
</style>
</noscript><meta property="og:url" content="./posts/">
<meta property="og:site_name" content="PINME Blog">
<meta property="og:title" content="Posts">
<meta property="og:locale" content="zh-cn">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Posts">
<meta name="twitter:description" content="">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1 ,
"name": "Posts",
"item": "./posts/"
}
]
}
</script>
</head>
<body class="list" id="top">
<script>
if (localStorage.getItem("pref-theme") === "dark") {
document.body.classList.add('dark');
} else if (localStorage.getItem("pref-theme") === "light") {
document.body.classList.remove('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
</script>
<header class="header">
<nav class="nav">
<div class="logo">
<a href="./" accesskey="h" title="PINME Blog (Alt + H)">PINME Blog</a>
<div class="logo-switches">
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg id="sun" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</div>
<ul id="menu">
</ul>
</nav>
</header>
<main class="main">
<header class="page-header">
<h1>
Posts
</h1>
</header>
<article class="post-entry">
<header class="entry-header">
<h2 class="entry-hint-parent">Ecommerce Intro
</h2>
</header>
<div class="entry-content">
<p>Hello World</p>
</div>
<footer class="entry-footer"><span title='2025-04-24 18:23:16 +0800 CST'>April 24, 2025</span></footer>
<a class="entry-link" aria-label="post link to Ecommerce Intro" href="./posts/ecommerce-intro/"></a>
</article>
</main>
<footer class="footer">
<span>&copy; 2025 <a href="./">PINME Blog</a></span> ·
<span>
Powered by
<a href="https://gohugo.io/" rel="noopener noreferrer" target="_blank">Hugo</a> &
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
</span>
</footer>
<a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
<path d="M12 6H0l6-6z" />
</svg>
</a>
<script>
let menu = document.getElementById('menu')
if (menu) {
menu.scrollLeft = localStorage.getItem("menu-scroll-position");
menu.onscroll = function () {
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
}
}
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
var id = this.getAttribute("href").substr(1);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView({
behavior: "smooth"
});
} else {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView();
}
if (id === "top") {
history.replaceState(null, null, " ");
} else {
history.pushState(null, null, `#${id}`);
}
});
});
</script>
<script>
var mybutton = document.getElementById("top-link");
window.onscroll = function () {
if (document.body.scrollTop > 800 || document.documentElement.scrollTop > 800) {
mybutton.style.visibility = "visible";
mybutton.style.opacity = "1";
} else {
mybutton.style.visibility = "hidden";
mybutton.style.opacity = "0";
}
};
</script>
<script>
document.getElementById("theme-toggle").addEventListener("click", () => {
if (document.body.className.includes("dark")) {
document.body.classList.remove('dark');
localStorage.setItem("pref-theme", 'light');
} else {
document.body.classList.add('dark');
localStorage.setItem("pref-theme", 'dark');
}
})
</script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Posts on PINME Blog</title>
<link>./posts/</link>
<description>Recent content in Posts on PINME Blog</description>
<generator>Hugo -- 0.146.7</generator>
<language>zh-cn</language>
<lastBuildDate>Thu, 24 Apr 2025 18:23:16 +0800</lastBuildDate>
<atom:link href="./posts/index.xml" rel="self" type="application/rss+xml" />
<item>
<title>Ecommerce Intro</title>
<link>./posts/ecommerce-intro/</link>
<pubDate>Thu, 24 Apr 2025 18:23:16 +0800</pubDate>
<guid>./posts/ecommerce-intro/</guid>
<description>&lt;h1 id=&#34;hello-world&#34;&gt;Hello World&lt;/h1&gt;</description>
</item>
</channel>
</rss>
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<title>./posts/</title>
<link rel="canonical" href="./posts/">
<meta name="robots" content="noindex">
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=./posts/">
</head>
</html>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>./posts/ecommerce-intro/</loc>
<lastmod>2025-04-24T18:23:16+08:00</lastmod>
</url><url>
<loc>./</loc>
<lastmod>2025-04-24T18:23:16+08:00</lastmod>
</url><url>
<loc>./posts/</loc>
<lastmod>2025-04-24T18:23:16+08:00</lastmod>
</url><url>
<loc>./categories/</loc>
</url><url>
<loc>./tags/</loc>
</url>
</urlset>
+193
View File
@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="en" dir="auto">
<head><meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="index, follow">
<title>Tags | PINME Blog</title>
<meta name="keywords" content="">
<meta name="description" content="">
<meta name="author" content="">
<link rel="canonical" href="./tags/">
<link crossorigin="anonymous" href="./assets/css/stylesheet.f49d66caae9ea0fd43f21f29e71a8d3e284517ed770f2aa86fa012953ad3c9ef.css" integrity="sha256-9J1myq6eoP1D8h8p5xqNPihFF&#43;13Dyqob6ASlTrTye8=" rel="preload stylesheet" as="style">
<link rel="icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="mask-icon" href="safari-pinned-tab.svg">
<meta name="theme-color" content="#2e2e33">
<meta name="msapplication-TileColor" content="#2e2e33">
<link rel="alternate" type="application/rss+xml" href="./tags/index.xml">
<link rel="alternate" hreflang="en" href="./tags/">
<noscript>
<style>
#theme-toggle,
.top-link {
display: none;
}
</style>
<style>
@media (prefers-color-scheme: dark) {
:root {
--theme: rgb(29, 30, 32);
--entry: rgb(46, 46, 51);
--primary: rgb(218, 218, 219);
--secondary: rgb(155, 156, 157);
--tertiary: rgb(65, 66, 68);
--content: rgb(196, 196, 197);
--code-block-bg: rgb(46, 46, 51);
--code-bg: rgb(55, 56, 62);
--border: rgb(51, 51, 51);
}
.list {
background: var(--theme);
}
.list:not(.dark)::-webkit-scrollbar-track {
background: 0 0;
}
.list:not(.dark)::-webkit-scrollbar-thumb {
border-color: var(--theme);
}
}
</style>
</noscript><meta property="og:url" content="./tags/">
<meta property="og:site_name" content="PINME Blog">
<meta property="og:title" content="Tags">
<meta property="og:locale" content="zh-cn">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Tags">
<meta name="twitter:description" content="">
</head>
<body class="list" id="top">
<script>
if (localStorage.getItem("pref-theme") === "dark") {
document.body.classList.add('dark');
} else if (localStorage.getItem("pref-theme") === "light") {
document.body.classList.remove('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
</script>
<header class="header">
<nav class="nav">
<div class="logo">
<a href="./" accesskey="h" title="PINME Blog (Alt + H)">PINME Blog</a>
<div class="logo-switches">
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg id="sun" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</div>
<ul id="menu">
</ul>
</nav>
</header>
<main class="main">
<header class="page-header">
<h1>Tags</h1>
</header>
<ul class="terms-tags">
</ul>
</main>
<footer class="footer">
<span>&copy; 2025 <a href="./">PINME Blog</a></span> ·
<span>
Powered by
<a href="https://gohugo.io/" rel="noopener noreferrer" target="_blank">Hugo</a> &
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
</span>
</footer>
<a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
<path d="M12 6H0l6-6z" />
</svg>
</a>
<script>
let menu = document.getElementById('menu')
if (menu) {
menu.scrollLeft = localStorage.getItem("menu-scroll-position");
menu.onscroll = function () {
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
}
}
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
var id = this.getAttribute("href").substr(1);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView({
behavior: "smooth"
});
} else {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView();
}
if (id === "top") {
history.replaceState(null, null, " ");
} else {
history.pushState(null, null, `#${id}`);
}
});
});
</script>
<script>
var mybutton = document.getElementById("top-link");
window.onscroll = function () {
if (document.body.scrollTop > 800 || document.documentElement.scrollTop > 800) {
mybutton.style.visibility = "visible";
mybutton.style.opacity = "1";
} else {
mybutton.style.visibility = "hidden";
mybutton.style.opacity = "0";
}
};
</script>
<script>
document.getElementById("theme-toggle").addEventListener("click", () => {
if (document.body.className.includes("dark")) {
document.body.classList.remove('dark');
localStorage.setItem("pref-theme", 'light');
} else {
document.body.classList.add('dark');
localStorage.setItem("pref-theme", 'dark');
}
})
</script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Tags on PINME Blog</title>
<link>./tags/</link>
<description>Recent content in Tags on PINME Blog</description>
<generator>Hugo -- 0.146.7</generator>
<language>zh-cn</language>
<atom:link href="./tags/index.xml" rel="self" type="application/rss+xml" />
</channel>
</rss>
+5
View File
@@ -0,0 +1,5 @@
# Update these with your Supabase details from your project settings > API
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
NEXT_SITE_URL=http://localhost:3000
NEXT_REDIRECT_URLS=http://localhost:3000/
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+72
View File
@@ -0,0 +1,72 @@
# PinMe
[PinMe](https://pinme.eth.limo/) is a simple and easy-to-use command-line tool for uploading files and directories to the [IPFS](https://ipfs.tech/) network.
Website: [https://pinme.eth.limo/](https://pinme.eth.limo/)
## Features
- Simple and intuitive command-line interface
- Fast uploading of files and directories to IPFS
- Automatic generation and display of access links
- Built-in Pinning service integration for persistent content
- Support for various configuration options
## Installing PinMe
Before using this deployment blog, please install the PinMe tool:
```bash
npm install -g pinme
# or
yarn global add pinme
```
# PinMe Deploy Blog
This is a Supabase-based blog project that can be easily deployed to the IPFS network.
## Project Structure
```
./
├── src/ # Source code directory
├── public/ # Static assets directory
├── components/ # Components directory
└── dist/ # Build output directory
```
## Installation
```bash
pnpm install
```
## Local Preview
```bash
pnpm run dev
```
## Build Project
```bash
pnpm run build
```
## Deploy to IPFS
```bash
pinme upload ./dist
```
## After Deployment
After successful deployment, PinMe will output the CID and access links. You can access your blog via:
- IPFS Gateway: `https://ipfs.io/ipfs/<your-CID>`
- Or set up a custom domain (see PinMe documentation)
## Contributing
Issues and Pull Requests are welcome to help improve this project.
## License
MIT
+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{
"name": "superbase-demo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@supabase/auth-helpers-react": "^0.3.1",
"@supabase/auth-ui-react": "^0.2.8",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.8.0",
"eslint": "8.34.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/node": "18.14.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1"
}
}
+2418
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+19
View File
@@ -0,0 +1,19 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
}
/* If footer needs to be added */
.footer {
margin-top: auto;
padding: 1rem;
background-color: #f3f4f6;
text-align: center;
font-size: 0.875rem;
color: #6b7280;
}
+19
View File
@@ -0,0 +1,19 @@
import { Routes, Route, HashRouter } from "react-router-dom";
import Home from "./pages/Home";
import "./App.css";
const App = (): JSX.Element => {
return (
<div className="app">
<div className="main-content">
<HashRouter>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</HashRouter>
</div>
</div>
);
};
export default App;

Some files were not shown because too many files have changed in this diff Show More