diff --git a/apps/web/app/cli-auth/CopyToken.tsx b/apps/web/app/cli-auth/CopyToken.tsx
new file mode 100644
index 0000000..bd0bed1
--- /dev/null
+++ b/apps/web/app/cli-auth/CopyToken.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { useState } from 'react';
+
+export default function CopyToken({ token }: { token: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(token);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ /* clipboard may be unavailable; user can still select the text */
+ }
+ };
+
+ return (
+
+
+ {token}
+
+
+ {copied ? 'Copied ✓' : 'Copy code'}
+
+
+ );
+}
diff --git a/apps/web/app/cli-auth/page.tsx b/apps/web/app/cli-auth/page.tsx
new file mode 100644
index 0000000..25d934b
--- /dev/null
+++ b/apps/web/app/cli-auth/page.tsx
@@ -0,0 +1,49 @@
+export const dynamic = 'force-dynamic';
+
+import type { Metadata } from 'next';
+import { cookies } from 'next/headers';
+import { COOKIE, parseSession } from '@/lib/auth';
+import CopyToken from './CopyToken';
+
+export const metadata: Metadata = {
+ title: 'CLI Login',
+ robots: { index: false, follow: false },
+};
+
+export default async function CliAuthPage() {
+ const jar = await cookies();
+ const token = jar.get(COOKIE)?.value ?? null;
+ const did = token ? await parseSession(token) : null;
+
+ return (
+
+
Connect the CLI
+
+ {did && token ? (
+ <>
+
+ You're signed in as {did.slice(0, 20)}… .
+ Copy the code below and paste it back into your terminal.
+
+
+
+ Treat this code like a password — it grants access to post coupons and bounties as you for
+ 30 days. Run c0upons logout on your machine to forget it.
+
+ >
+ ) : (
+ <>
+
+ Sign in with CoinPay, then you'll get a code to paste into the CLI.
+
+
+ Connect with CoinPay
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts
index b996530..cebeae6 100644
--- a/apps/web/lib/auth.ts
+++ b/apps/web/lib/auth.ts
@@ -1,5 +1,5 @@
import 'server-only';
-import { cookies } from 'next/headers';
+import { cookies, headers } from 'next/headers';
const COOKIE = 'cp_session';
const enc = new TextEncoder();
@@ -45,6 +45,14 @@ export async function parseSession(value: string): Promise {
export async function getSessionDid(): Promise {
try {
+ // CLI / API clients authenticate with the same signed session token sent as
+ // `Authorization: Bearer `. Browser requests use the httpOnly cookie.
+ const hdrs = await headers();
+ const auth = hdrs.get('authorization');
+ if (auth?.startsWith('Bearer ')) {
+ const did = await parseSession(auth.slice(7).trim());
+ if (did) return did;
+ }
const jar = await cookies();
const val = jar.get(COOKIE)?.value;
if (!val) return null;
diff --git a/apps/web/public/cli/c0upons b/apps/web/public/cli/c0upons
index 3f78e22..896cb25 100644
--- a/apps/web/public/cli/c0upons
+++ b/apps/web/public/cli/c0upons
@@ -6,8 +6,13 @@
set -euo pipefail
-VERSION="1.0.0"
+VERSION="1.1.0"
BASE_URL="${C0UPONS_API:-https://c0upons.com/api}"
+# Website root (for login + share links). Defaults to BASE_URL without /api.
+WEB_URL="${C0UPONS_WEB:-${BASE_URL%/api}}"
+
+CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/c0upons"
+TOKEN_FILE="${CONFIG_DIR}/token"
BOLD="\033[1m"
DIM="\033[2m"
@@ -27,6 +32,10 @@ usage() {
echo " latest Show latest/trending coupons"
echo " stores List all stores"
echo " store Show coupons for a store"
+ echo " login Connect your CoinPay account"
+ echo " logout Forget saved credentials"
+ echo " submit Submit a coupon (requires login)"
+ echo " bounty Post a coupon bounty (requires login)"
echo " upgrade Upgrade to the latest version (alias: update)"
echo " remove Uninstall the CLI (alias: uninstall)"
echo " version Print version"
@@ -34,9 +43,9 @@ usage() {
echo ""
echo -e "${BOLD}EXAMPLES${RESET}"
echo " c0upons search nike"
- echo " c0upons latest"
- echo " c0upons stores"
- echo " c0upons store adidas"
+ echo " c0upons login"
+ echo " c0upons submit --store Nike --title '20% off sitewide' --code SAVE20 --percent 20 --url https://nike.com"
+ echo " c0upons bounty --store Adidas --title 'Need a 30% off code' --reward 1.00"
echo ""
echo -e "${DIM}Override API: export C0UPONS_API=http://localhost:3000/api${RESET}"
echo -e "${DIM}Docs: https://c0upons.com/docs${RESET}"
@@ -52,6 +61,35 @@ require_jq() {
fi
}
+require_auth() {
+ if [ ! -f "$TOKEN_FILE" ] || [ ! -s "$TOKEN_FILE" ]; then
+ echo -e "${RED}Not logged in.${RESET} Run: ${BOLD}c0upons login${RESET}"
+ exit 1
+ fi
+ TOKEN=$(cat "$TOKEN_FILE")
+}
+
+# api_post — sends an authenticated POST.
+# On success, leaves the response body in $API_BODY. On failure, prints the
+# error and exits.
+api_post() {
+ local path="$1" payload="$2" resp code
+ resp=$(curl -sS -w $'\n%{http_code}' -X POST "${BASE_URL}${path}" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer ${TOKEN}" \
+ -d "$payload") || { echo -e "${RED}Network error.${RESET} Could not reach ${BASE_URL}."; exit 1; }
+ code=$(printf '%s' "$resp" | tail -n1)
+ API_BODY=$(printf '%s' "$resp" | sed '$d')
+ if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
+ return 0
+ fi
+ local msg
+ msg=$(printf '%s' "$API_BODY" | jq -r '.error // empty' 2>/dev/null || true)
+ echo -e "${RED}Error (${code}):${RESET} ${msg:-request failed}"
+ [ "$code" = "401" ] && echo -e " Run: ${BOLD}c0upons login${RESET}"
+ exit 1
+}
+
urlencode() {
local raw="$1"
if command -v python3 &>/dev/null; then
@@ -156,6 +194,126 @@ cmd_store() {
done <<< "$coupons"
}
+cmd_login() {
+ require_jq
+ echo -e "To connect the CLI, open this URL and sign in with CoinPay:"
+ echo ""
+ echo -e " ${BOLD}${WEB_URL}/cli-auth${RESET}"
+ echo ""
+ printf "Paste the code here: "
+ local token
+ read -r token
+ token=$(printf '%s' "$token" | tr -d '[:space:]')
+ if [ -z "$token" ]; then
+ echo -e "${RED}No code entered.${RESET}"
+ exit 1
+ fi
+ local did
+ did=$(curl -fsSL -H "Authorization: Bearer ${token}" "${BASE_URL}/auth/me" 2>/dev/null | jq -r '.did // empty') || true
+ if [ -z "$did" ]; then
+ echo -e "${RED}That code didn't work.${RESET} Make sure you copied the whole thing."
+ exit 1
+ fi
+ mkdir -p "$CONFIG_DIR"
+ printf '%s' "$token" > "$TOKEN_FILE"
+ chmod 600 "$TOKEN_FILE"
+ echo -e "${GREEN}✓${RESET} Logged in as ${BOLD}${did}${RESET}"
+}
+
+cmd_logout() {
+ if [ -f "$TOKEN_FILE" ]; then
+ rm -f "$TOKEN_FILE"
+ echo -e "${GREEN}✓${RESET} Logged out."
+ else
+ echo "Not logged in."
+ fi
+}
+
+cmd_submit() {
+ require_jq
+ require_auth
+ local title="" store="" code="" url="" website="" description="" expiry="" dtype="" dvalue=""
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --title) title="${2:-}"; shift 2 ;;
+ --store) store="${2:-}"; shift 2 ;;
+ --code) code="${2:-}"; shift 2 ;;
+ --url) url="${2:-}"; shift 2 ;;
+ --website) website="${2:-}"; shift 2 ;;
+ --description|--desc) description="${2:-}"; shift 2 ;;
+ --expiry) expiry="${2:-}"; shift 2 ;;
+ --percent) dtype="percent"; dvalue="${2:-}"; shift 2 ;;
+ --off) dtype="fixed"; dvalue="${2:-}"; shift 2 ;;
+ *) echo -e "${RED}Unknown option:${RESET} $1"; exit 1 ;;
+ esac
+ done
+ if [ -z "$title" ]; then
+ echo "Usage: c0upons submit --title [--store ] [--code ]"
+ echo " [--percent | --off ] [--url ]"
+ echo " [--description ] [--expiry YYYY-MM-DD]"
+ exit 1
+ fi
+ local payload
+ payload=$(jq -n \
+ --arg title "$title" --arg store "$store" --arg code "$code" --arg url "$url" \
+ --arg website "$website" --arg description "$description" --arg expiry "$expiry" \
+ --arg dtype "$dtype" --arg dvalue "$dvalue" \
+ '{
+ title: $title,
+ store_name: (if $store == "" then null else $store end),
+ code: (if $code == "" then null else $code end),
+ url: (if $url == "" then null else $url end),
+ store_website: (if $website == "" then null else $website end),
+ description: (if $description == "" then null else $description end),
+ expiry_date: (if $expiry == "" then null else $expiry end),
+ discount_type: (if $dtype == "" then null else $dtype end),
+ discount_value: (if $dvalue == "" then null else $dvalue end)
+ }')
+ api_post "/coupons" "$payload"
+ echo -e "${GREEN}✓${RESET} Coupon submitted!"
+}
+
+cmd_bounty() {
+ require_jq
+ require_auth
+ local title="" store="" reward="" url="" description=""
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --title) title="${2:-}"; shift 2 ;;
+ --store) store="${2:-}"; shift 2 ;;
+ --reward) reward="${2:-}"; shift 2 ;;
+ --url) url="${2:-}"; shift 2 ;;
+ --description|--desc) description="${2:-}"; shift 2 ;;
+ *) echo -e "${RED}Unknown option:${RESET} $1"; exit 1 ;;
+ esac
+ done
+ if [ -z "$title" ] || [ -z "$store" ] || [ -z "$reward" ]; then
+ echo "Usage: c0upons bounty --title --store --reward "
+ echo " [--url ] [--description ]"
+ exit 1
+ fi
+ local payload
+ payload=$(jq -n \
+ --arg title "$title" --arg store "$store" --arg reward "$reward" \
+ --arg url "$url" --arg description "$description" \
+ '{
+ title: $title,
+ store_name: $store,
+ reward_usd: $reward,
+ url: (if $url == "" then null else $url end),
+ description: (if $description == "" then null else $description end)
+ }')
+ api_post "/bounties" "$payload"
+ local pubid pay_url
+ pubid=$(printf '%s' "$API_BODY" | jq -r '.public_id // .id // empty')
+ pay_url=$(printf '%s' "$API_BODY" | jq -r '.pay_url // empty')
+ echo -e "${GREEN}✓${RESET} Bounty posted!"
+ [ -n "$pubid" ] && echo -e " ${DIM}${WEB_URL}/bounties/${pubid}${RESET}"
+ if [ -n "$pay_url" ]; then
+ echo -e " Fund it to make it live: ${BOLD}${pay_url}${RESET}"
+ fi
+}
+
cmd_upgrade() {
local self
self="$(command -v c0upons 2>/dev/null || echo "")"
@@ -206,6 +364,10 @@ case "${1:-help}" in
latest) cmd_latest ;;
stores) cmd_stores ;;
store) shift; cmd_store "$@" ;;
+ login) cmd_login ;;
+ logout) cmd_logout ;;
+ submit) shift; cmd_submit "$@" ;;
+ bounty) shift; cmd_bounty "$@" ;;
upgrade|update) cmd_upgrade ;;
remove|uninstall) cmd_remove ;;
version|--version|-v) echo "c0upons v${VERSION}" ;;