GhostMark
A minimalist, single-file, serverless bookmark manager.
- Runtime: Vercel Serverless Functions
- Database: Secret GitHub Gist
- Frontend: SSR HTML with PWA support
Features
- Dark mode UI with clean, minimal design
- PWA support (installable on iOS/Android)
- CLI for terminal usage
- iOS Scriptable integration for quick saving from Share Sheet
- Duplicate URL prevention
- Delete bookmarks from web UI or CLI
- No external dependencies (native
fetchonly)
Setup
1. Create a Secret Gist
- Go to gist.github.com
- Create a Secret gist with:
- Filename:
bookmarks.json - Content:
[]
- Filename:
- Save and copy the Gist ID from the URL:
https://gist.github.com/username/THIS_IS_YOUR_GIST_ID
2. Generate a GitHub Personal Access Token
- Go to GitHub Settings → Developer settings → Personal access tokens
- Generate a new token (classic) with the
gistscope - Copy the token
3. Generate a Secret Key
4. Deploy to Vercel
5. Set Environment Variables
In your Vercel dashboard (Settings → Environment Variables), add:
| Variable | Value |
|---|---|
GIST_ID |
Your GitHub Gist ID |
GH_TOKEN |
Your GitHub Personal Access Token |
GHOSTMARK_KEY |
Your generated secret key |
Redeploy after adding environment variables:
Usage
Browser
Visit your deployed URL with your key:
https://your-app.vercel.app/?key=YOUR_KEY
Add to home screen for PWA experience (removes browser UI on iOS).
CLI
Add to your ~/.zshrc or ~/.bashrc:
# GhostMark CLI ghostmark() { local endpoint="$GHOSTMARK_URL" local key="$GHOSTMARK_KEY" if [[ -z "$endpoint" || -z "$key" ]]; then echo "Error: GHOSTMARK_URL and GHOSTMARK_KEY must be set" return 1 fi if [[ "$1" == "-l" && -n "$2" ]]; then # Add bookmark response=$(curl -s -X POST "$endpoint" \ -H "Authorization: Bearer $key" \ -H "Content-Type: application/json" \ -d "{\"url\": \"$2\"}") if echo "$response" | grep -q '"status":"saved"'; then echo "✓ Saved" elif echo "$response" | grep -q '"error"'; then echo "✗ $(echo "$response" | jq -r '.error')" else echo "✗ Unknown error" fi elif [[ "$1" == "-d" ]]; then # Delete bookmark local search="$2" local bookmarks=$(curl -s "$endpoint" \ -H "Authorization: Bearer $key" \ -H "Accept: application/json") # Filter and format bookmarks local filtered if [[ -n "$search" ]]; then filtered=$(echo "$bookmarks" | jq -r --arg s "$search" \ '[.[] | select(.url | ascii_downcase | contains($s | ascii_downcase))] | .[:20]') else filtered=$(echo "$bookmarks" | jq -r '.[:20]') fi local count=$(echo "$filtered" | jq -r 'length') if [[ "$count" == "0" ]]; then if [[ -n "$search" ]]; then echo "No bookmarks found matching '$search'" else echo "No bookmarks found" fi return 0 fi # Display numbered list echo "" if [[ -n "$search" ]]; then echo "Bookmarks matching '$search' (showing up to 20):" else echo "Recent bookmarks (showing up to 20):" fi echo "" echo "$filtered" | jq -r 'to_entries | .[] | " \(.key + 1). \(.value.date[0:10]) \(.value.url | sub("^https?://"; "") | sub("/$"; ""))"' echo "" # Prompt for selection echo -n "Enter number to delete (or 'q' to cancel): " read selection if [[ "$selection" == "q" || -z "$selection" ]]; then echo "Cancelled" return 0 fi # Validate number if ! [[ "$selection" =~ ^[0-9]+$ ]] || [[ "$selection" -lt 1 ]] || [[ "$selection" -gt "$count" ]]; then echo "✗ Invalid selection" return 1 fi # Get URL at index local url=$(echo "$filtered" | jq -r ".[$((selection - 1))].url") # Delete response=$(curl -s -X DELETE "$endpoint" \ -H "Authorization: Bearer $key" \ -H "Content-Type: application/json" \ -d "{\"url\": \"$url\"}") if echo "$response" | grep -q '"status":"deleted"'; then echo "✓ Deleted" elif echo "$response" | grep -q '"error"'; then echo "✗ $(echo "$response" | jq -r '.error')" else echo "✗ Unknown error" fi else # List bookmarks curl -s "$endpoint" \ -H "Authorization: Bearer $key" \ -H "Accept: application/json" | \ jq -r '.[] | "\(.date[0:10]) \(.url)"' fi } export GHOSTMARK_URL="https://your-app.vercel.app/api" export GHOSTMARK_KEY="your-secret-key"
Then reload your shell:
Commands:
ghostmark # List all bookmarks ghostmark -l <url> # Save a bookmark ghostmark -d # Delete (shows recent 20, pick by number) ghostmark -d <search> # Delete (filter by search term first)
iOS (Scriptable)
Install Scriptable from the App Store, create a new script, and paste:
// GhostMark - Save Bookmark // Run from Share Sheet to save URLs const GHOSTMARK_URL = "https://your-app.vercel.app/api"; const GHOSTMARK_KEY = "your-secret-key"; async function saveBookmark(url) { const req = new Request(GHOSTMARK_URL); req.method = "POST"; req.headers = { "Authorization": `Bearer ${GHOSTMARK_KEY}`, "Content-Type": "application/json" }; req.body = JSON.stringify({ url }); try { const res = await req.loadJSON(); if (res.status === "saved") { const notification = new Notification(); notification.title = "GhostMark"; notification.body = "✓ Saved"; notification.schedule(); } else if (res.error) { const notification = new Notification(); notification.title = "GhostMark"; notification.body = `✗ ${res.error}`; notification.schedule(); } } catch (e) { const notification = new Notification(); notification.title = "GhostMark"; notification.body = "✗ Failed to save"; notification.schedule(); } } // Get URL from Share Sheet if (args.urls && args.urls.length > 0) { await saveBookmark(args.urls[0]); } else if (args.plainTexts && args.plainTexts.length > 0) { const text = args.plainTexts[0]; if (text.startsWith("http")) { await saveBookmark(text); } else { const alert = new Alert(); alert.title = "GhostMark"; alert.message = "No URL found"; alert.present(); } } else { const alert = new Alert(); alert.title = "GhostMark"; alert.message = "Share a URL to save it"; alert.present(); } Script.complete();
Setup:
- Tap the script settings icon
- Enable Share Sheet
- Share any URL → Select Scriptable → Select "GhostMark"
API Reference
Authentication
All requests require authentication via:
- Header:
Authorization: Bearer YOUR_KEY - Query param:
?key=YOUR_KEY
Endpoints
GET /api
List all bookmarks.
Headers:
Accept: application/json→ Returns JSON array- Default → Returns HTML page
Response (JSON):
[
{ "url": "https://example.com", "date": "2025-01-07T12:00:00.000Z" }
]POST /api
Add a new bookmark.
Body:
{ "url": "https://example.com" }Response:
Errors:
400— URL is required401— Unauthorized409— URL already bookmarked
DELETE /api
Delete a bookmark.
Body:
{ "url": "https://example.com" }Response:
Errors:
400— URL is required401— Unauthorized404— Bookmark not found
Environment Variables
| Variable | Description |
|---|---|
GIST_ID |
GitHub Gist ID (from gist URL) |
GH_TOKEN |
GitHub Personal Access Token (gist scope) |
GHOSTMARK_KEY |
Secret key for API authentication |
License
MIT