chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
@@ -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
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
/bin
|
||||
/src
|
||||
.github
|
||||
.gitignore
|
||||
.prettierrc
|
||||
rollup.config.js
|
||||
tsconfig.json
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,2 @@
|
||||
/dist
|
||||
*.yaml
|
||||
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
pluginSearchDirs: false,
|
||||
plugins: [
|
||||
],
|
||||
printWidth: 80,
|
||||
proseWrap: 'never',
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
overrides: [
|
||||
{
|
||||
files: '*.md',
|
||||
options: {
|
||||
proseWrap: 'preserve',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`glitternetwork/pinme`
|
||||
- 原始仓库:https://github.com/glitternetwork/pinme
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+215
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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('');
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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));
|
||||
@@ -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 ./
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
* [Home](/)
|
||||
* [Contact](./contact/index.md)
|
||||
@@ -0,0 +1,5 @@
|
||||
* [Home](./)
|
||||
* [Quick Start](./quickstart)
|
||||
* Guide
|
||||
* [Install](./guide/installation)
|
||||
* [Config](./guide/config)
|
||||
@@ -0,0 +1,3 @@
|
||||
* [Contact](./contact/index)
|
||||
* [test](./contact/test)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Contact
|
||||
|
||||
[Glitter](https://glitterprotocol.io/)
|
||||
@@ -0,0 +1,3 @@
|
||||
# test
|
||||
|
||||
test page
|
||||
@@ -0,0 +1,2 @@
|
||||
# config
|
||||
pinme -help
|
||||
@@ -0,0 +1,5 @@
|
||||
# install
|
||||
|
||||
```bash
|
||||
npm -g install pinme
|
||||
```
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
# Quick Start
|
||||
quick start
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
+++
|
||||
date = '{{ .Date }}'
|
||||
draft = true
|
||||
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||
+++
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
languageCode = "en-us"
|
||||
title = "PINME Blog"
|
||||
theme = "PaperMod"
|
||||
baseURL = "./"
|
||||
|
||||
|
||||
[params]
|
||||
relativeURLs = true
|
||||
canonifyURLs = false
|
||||
@@ -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+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>© 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>
|
||||
+7
File diff suppressed because one or more lines are too long
@@ -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+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>© 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>
|
||||
@@ -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+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>© 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,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><h1 id="hello-world">Hello World</h1></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+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>© 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,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+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>© 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,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><h1 id="hello-world">Hello World</h1></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>
|
||||
@@ -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>
|
||||
@@ -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+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>© 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>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>
|
||||
@@ -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/
|
||||
@@ -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?
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2418
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user