11 Commits

Author SHA1 Message Date
bf96fb9763 feat: Prevent website behaviors in PWA
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
Merge #1
Reviewed-on: sortedcord/brew#1
2026-06-06 19:44:49 +05:30
f2af73ac69 feat: prevent pull to refresh on phones
- disable y overscroll
2026-06-06 19:36:22 +05:30
1195159c3d feat: try disabling pinch to zoom in on phones 2026-06-06 19:31:53 +05:30
4e234d075f feat: Improved sync editor
All checks were successful
Deploy Brew Application / deploy (push) Successful in 10s
2026-06-06 19:18:30 +05:30
a0b1efd242 fix: prevent shallow clone and get full history with tags
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 18:10:30 +05:30
e808aa8a37 fix: getGitVersion incorrectly stating dirty builds in production
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 18:06:50 +05:30
4a9f6b6266 chore: Fix pm2 process duplication and form icons
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 15:42:52 +05:30
592ccf0a92 docs: Update logos
All checks were successful
Deploy Brew Application / deploy (push) Successful in 11s
2026-06-06 15:35:05 +05:30
f8a1f2cbdd fix: branch name
All checks were successful
Deploy Brew Application / deploy (push) Successful in 17s
2026-06-06 14:08:54 +05:30
1064c724f7 feat: setup CD 2026-06-06 14:07:50 +05:30
9d98966a8e feat: improve profile page 2026-06-06 12:31:27 +05:30
17 changed files with 248 additions and 63 deletions

View File

@@ -0,0 +1,35 @@
name: Deploy Brew Application
on:
push:
branches:
- master
jobs:
deploy:
runs-on: production
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build Frontend
env:
CI: "true"
run: |
npm install
rm -rf dist/
npm run build
- name: Deploy Frontend Files
run: |
rm -rf /var/www/brew/dist/
cp -r dist/ /var/www/brew/
- name: Restart Backend with PM2
run: |
cd server
npm install
pm2 restart brew-backend || pm2 start index.js --name "brew-backend"

View File

@@ -1,28 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#2C1810" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>brew</title>
<script>
(function() {
try {
const theme = localStorage.getItem('brew-theme') || 'system';
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/brew_favicons/brew_64.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#2C1810" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Brew Journal</title>
<script>
(function () {
try {
const theme = localStorage.getItem('brew-theme') || 'system';
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) { }
})();
// Prevent pinch-to-zoom gesture on iOS devices
document.addEventListener('gesturestart', function (e) {
e.preventDefault();
});
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/icon-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 164 KiB

View File

@@ -15,6 +15,7 @@ import BrewCard from "./components/BrewCard";
import BottomNav from "./components/BottomNav";
import IosPromptModal from "./components/IosPromptModal";
import UpdatePrompt from "./components/UpdatePrompt";
import SyncIndicator from "./components/SyncIndicator";
// Import constants
@@ -196,13 +197,7 @@ export default function CoffeeLogbook() {
<div className="text-[10px] text-[#9C8B7A] dark:text-[#C8B9A6] font-semibold tracking-[1.5px] uppercase">{pageSubtitles[view] || "Coffee Logbook"}</div>
<h1 className="font-serif text-[21px] font-semibold tracking-tight text-[#2C1810] dark:text-[#FAF6F1] mt-0.5">{pageTitles[view] || "Brew Journal"}</h1>
</div>
{showSyncedStatus && (
<div className={`flex items-center gap-1 text-[11px] font-medium px-2.5 py-1 rounded-full transition-all ${isOnline ? "text-[#4A7C59] bg-[rgba(74,124,89,0.1)] dark:text-[#6CB281] dark:bg-[rgba(108,178,129,0.15)]" : "text-[#B44040] bg-[rgba(180,64,64,0.1)] dark:text-[#E55B5B] dark:bg-[rgba(229,91,91,0.15)]"}`}
title={isOnline ? "Synchronized" : "Offline"}>
<span className={`text-[10px] ${syncing ? "animate-sync-pulse" : ""}`}></span>
<span>{isOnline ? (syncing ? "Syncing..." : "Synced") : "Offline"}</span>
</div>
)}
<SyncIndicator isOnline={isOnline} syncing={syncing} showSyncedStatus={showSyncedStatus} />
</div>
{/* Content */}

View File

@@ -57,8 +57,8 @@ export default function IosPromptModal() {
{/* Content */}
<div className="flex flex-col items-center text-center">
{/* Brew Logo */}
<div className="w-16 h-16 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-2xl flex items-center justify-center p-3 shadow-sm mb-3">
<img src="/favicon.svg" alt="Brew Logo" className="w-full h-full object-contain" />
<div className="w-16 h-16 bg-white dark:bg-[#22120B] border border-[#E8DFD3] dark:border-[#3B2217] rounded-[22%] flex items-center justify-center overflow-hidden shadow-sm mb-3">
<img src="/icon-192.png" alt="Brew Logo" className="w-full h-full object-cover" />
</div>
{/* Brew Text */}

View File

@@ -0,0 +1,69 @@
/**
* SyncIndicator — a minimal coffee-cup icon that communicates sync results.
*
* Success: outlined cup → steam rises → checkmark appears → fades out (~2.4s)
* Offline: outlined cup + subtle "!" warning, stays visible.
*/
export default function SyncIndicator({ isOnline, syncing, showSyncedStatus }) {
const showSuccess = isOnline && showSyncedStatus && !syncing;
const showOffline = !isOnline;
if (!showSuccess && !showOffline) return null;
return (
<div
className={showSuccess ? "sync-cup-success" : undefined}
title={showOffline ? "Offline — data saved locally" : "All data synced"}
>
<svg
viewBox="0 0 24 24"
width="28"
height="28"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
{/* ── Cup body ── */}
<g className="text-[#9C8B7A] dark:text-[#C8B9A6]">
<path
d="M5 9h10v6.5a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V9z"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15 11h1a2 2 0 0 1 0 4h-1"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
{/* ── Steam lines (success only) ── */}
{showSuccess && (
<g className="text-[#9C8B7A] dark:text-[#C8B9A6]" stroke="currentColor" strokeWidth="1.2">
<path className="sync-steam sync-steam-1" d="M8 7 Q8.7 5.5 8 4.5 Q7.5 3.8 8.2 3" />
<path className="sync-steam sync-steam-2" d="M10 6.5 Q10.7 5 10 4 Q9.5 3.3 10.2 2.5" />
<path className="sync-steam sync-steam-3" d="M12 7 Q12.7 5.5 12 4.5 Q11.5 3.8 12.2 3" />
</g>
)}
{/* ── Checkmark (success only) ── */}
{showSuccess && (
<polyline
points="7.5,14 9.5,16 13,12"
className="sync-check text-[#4A7C59] dark:text-[#6CB281]"
stroke="currentColor"
strokeWidth="1.8"
/>
)}
{/* ── Warning indicator (offline only) ── */}
{showOffline && (
<g className="text-[#B44040] dark:text-[#E55B5B]" style={{ opacity: 0.85 }}>
<line x1="10" y1="12" x2="10" y2="15.5" stroke="currentColor" strokeWidth="1.8" />
<circle cx="10" cy="17.2" r="0.8" fill="currentColor" stroke="none" />
</g>
)}
</svg>
</div>
);
}

View File

@@ -37,9 +37,9 @@ export default function UpdatePrompt() {
<div
className={`fixed inset-x-0 bottom-28 z-[150] flex justify-center px-4 transition-all duration-300 ease-out ${active ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="bg-[#2C1810] text-[#FAF6F1] rounded-2xl shadow-[0_8px_32px_rgba(44,24,16,0.35)] flex items-center gap-3 px-4 py-3.5 max-w-[420px] w-full">
<div className="bg-[#2C1810] text-[#FAF6F1] rounded-full shadow-[0_8px_32px_rgba(44,24,16,0.35)] flex items-center gap-3 pl-3 pr-4 py-2.5 max-w-[420px] w-full border border-white/5">
{/* Icon */}
<div className="w-9 h-9 rounded-xl bg-white/10 flex items-center justify-center flex-shrink-0">
<div className="w-9 h-9 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
<RefreshCw size={16} strokeWidth={2.5} className="text-white" />
</div>
@@ -53,13 +53,13 @@ export default function UpdatePrompt() {
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={handleDismiss}
className="text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer px-1 py-1"
className="text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer px-2 py-2"
>
Later
</button>
<button
onClick={handleUpdate}
className="text-xs font-semibold bg-white text-[#2C1810] px-3 py-1.5 rounded-lg hover:bg-white/90 transition-colors cursor-pointer"
className="text-xs font-semibold bg-white text-[#2C1810] px-4 py-2 rounded-full hover:bg-white/90 transition-colors cursor-pointer"
>
Update
</button>

View File

@@ -43,6 +43,8 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.2s ease, color 0.2s ease;
touch-action: pan-x pan-y;
overscroll-behavior-y: contain;
}
html.dark {
@@ -53,6 +55,8 @@
margin: 0;
background-color: #F3EDE4;
transition: background-color 0.2s ease, color 0.2s ease;
touch-action: pan-x pan-y;
overscroll-behavior-y: contain;
}
html.dark body {
@@ -138,4 +142,40 @@
width: 4px;
border-radius: 0 4px 4px 0;
}
/* ── Sync cup indicator ── */
.sync-cup-success {
animation: sync-cup-fade 2.4s ease-in-out forwards;
}
@keyframes sync-cup-fade {
0% { opacity: 0; transform: scale(0.88); }
8% { opacity: 1; transform: scale(1); }
68% { opacity: 1; }
100% { opacity: 0; }
}
.sync-steam {
opacity: 0;
}
.sync-steam-1 { animation: sync-steam-rise 1.4s ease-out 0.1s forwards; }
.sync-steam-2 { animation: sync-steam-rise 1.4s ease-out 0.3s forwards; }
.sync-steam-3 { animation: sync-steam-rise 1.4s ease-out 0.5s forwards; }
@keyframes sync-steam-rise {
0% { opacity: 0; transform: translateY(0); }
25% { opacity: 0.5; }
100% { opacity: 0; transform: translateY(-4px); }
}
.sync-check {
stroke-dasharray: 14;
stroke-dashoffset: 14;
animation: sync-check-draw 0.45s ease-out 0.65s forwards;
}
@keyframes sync-check-draw {
to { stroke-dashoffset: 0; }
}
}

View File

@@ -24,7 +24,7 @@ export default function Login({ onLoginSuccess }) {
return (
<div className="px-5 pt-12 pb-8 max-w-[480px] mx-auto transition-colors duration-200">
<div className="mb-8 text-center">
<div className="text-5xl mb-3"></div>
<img src="/icon-192.png" alt="Brew Journal" className="w-16 h-16 mx-auto mb-3 rounded-[22%]" />
<h1 className="font-serif text-3xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-1">Brew Journal</h1>
<p className="text-sm text-[#9C8B7A] dark:text-[#C8B9A6]">Sign in to your coffee logbook</p>
</div>

View File

@@ -2,6 +2,7 @@
import { useContext } from "react";
import { AuthContext } from "../AuthContext";
import { useTheme } from "../ThemeContext";
import { User, Mail, Contrast, Coffee, Database, MessageSquare } from "lucide-react";
export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus }) {
const { logout } = useContext(AuthContext);
@@ -18,16 +19,14 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
{/* Avatar & Name */}
<div className="flex flex-col items-center pt-6 pb-8">
<div className="w-20 h-20 rounded-full bg-[#2C1810] dark:bg-[#D4A325] flex items-center justify-center text-3xl text-[#FAF6F1] dark:text-[#2C1810] font-serif font-bold mb-3 shadow-sm">
{user?.username?.[0]?.toUpperCase() || "☕"}
{user?.username?.[0]?.toUpperCase() || <User size={32} strokeWidth={2} />}
</div>
<div className="font-serif text-xl font-semibold text-[#2C1810] dark:text-[#FAF6F1]">{user?.username}</div>
<div className="text-sm text-[#9C8B7A] dark:text-[#C8B9A6] mt-0.5">{user?.email}</div>
{showSyncedStatus && (
<div className={`flex items-center gap-1.5 text-[11px] font-medium px-3 py-1.5 rounded-full mt-3 transition-all ${isOnline ? "text-[#4A7C59] bg-[rgba(74,124,89,0.1)] dark:text-[#6CB281] dark:bg-[rgba(108,178,129,0.15)]" : "text-[#B44040] bg-[rgba(180,64,64,0.1)] dark:text-[#E55B5B] dark:bg-[rgba(229,91,91,0.15)]"}`}>
<span className={`text-[10px] ${syncing ? "animate-sync-pulse" : ""}`}></span>
<span>{isOnline ? (syncing ? "Syncing…" : "All data synced") : "Offline — saved locally"}</span>
</div>
)}
<div className={`flex items-center gap-1.5 text-[11px] font-medium px-3 py-1.5 rounded-full mt-3 transition-all ${isOnline ? "text-[#4A7C59] bg-[rgba(74,124,89,0.1)] dark:text-[#6CB281] dark:bg-[rgba(108,178,129,0.15)]" : "text-[#B44040] bg-[rgba(180,64,64,0.1)] dark:text-[#E55B5B] dark:bg-[rgba(229,91,91,0.15)]"}`}>
<span className={`text-[10px] ${syncing ? "animate-sync-pulse" : ""}`}></span>
<span>{isOnline ? (syncing ? "Syncing…" : "All data synced") : "Offline — saved locally"}</span>
</div>
</div>
{/* Account section */}
@@ -35,14 +34,14 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
<div className="text-[11px] font-semibold uppercase tracking-widest text-[#9C8B7A] dark:text-[#C8B9A6] mb-2 px-1">Account</div>
<div className="bg-white dark:bg-[#22120B] rounded-2xl border border-[#E8DFD3] dark:border-[#3B2217] overflow-hidden shadow-sm transition-colors duration-200">
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-[#F3EDE4] dark:border-[#3B2217]">
<span className="text-lg">👤</span>
<User size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Username</div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">{user?.username}</div>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-3.5">
<span className="text-lg"></span>
<Mail size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Email</div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">{user?.email}</div>
@@ -51,13 +50,22 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
</div>
</div>
{/* Sign out */}
<div className="mb-6">
<button
onClick={() => logout()}
className="w-full py-3.5 border border-[#B44040] dark:border-[#E55B5B] rounded-2xl text-sm font-semibold text-[#B44040] dark:text-[#E55B5B] bg-transparent cursor-pointer hover:bg-[rgba(180,64,64,0.05)] dark:hover:bg-[rgba(229,91,91,0.05)] transition-all">
Sign Out
</button>
</div>
{/* Appearance Settings section */}
<div className="mb-6">
<div className="text-[11px] font-semibold uppercase tracking-widest text-[#9C8B7A] dark:text-[#C8B9A6] mb-2 px-1">Appearance</div>
<div className="bg-white dark:bg-[#22120B] rounded-2xl border border-[#E8DFD3] dark:border-[#3B2217] p-4 flex flex-col gap-3 shadow-sm transition-colors duration-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-lg">🌓</span>
<Contrast size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">Theme</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Customize your viewing experience</div>
@@ -92,13 +100,13 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
<div className="bg-white dark:bg-[#22120B] rounded-2xl border border-[#E8DFD3] dark:border-[#3B2217] overflow-hidden shadow-sm transition-colors duration-200">
<div className="flex items-center justify-between px-4 py-3.5 border-b border-[#F3EDE4] dark:border-[#3B2217]">
<div className="flex items-center gap-3">
<span className="text-lg"></span>
<Coffee size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">Brew Journal</div>
</div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6] font-mono">{__APP_VERSION__}</div>
</div>
<div className="flex items-center gap-3 px-4 py-3.5">
<span className="text-lg">🗄</span>
<Database size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Storage</div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">Local + PostgreSQL</div>
@@ -107,12 +115,27 @@ export default function ProfilePage({ user, isOnline, syncing, showSyncedStatus
</div>
</div>
{/* Sign out */}
<button
onClick={() => logout()}
className="w-full py-3.5 border border-[#B44040] dark:border-[#E55B5B] rounded-2xl text-sm font-semibold text-[#B44040] dark:text-[#E55B5B] bg-transparent cursor-pointer hover:bg-[rgba(180,64,64,0.05)] dark:hover:bg-[rgba(229,91,91,0.05)] transition-all">
Sign Out
</button>
{/* Support section */}
<div className="mb-6">
<div className="text-[11px] font-semibold uppercase tracking-widest text-[#9C8B7A] dark:text-[#C8B9A6] mb-2 px-1">Support</div>
<div className="bg-white dark:bg-[#22120B] rounded-2xl border border-[#E8DFD3] dark:border-[#3B2217] overflow-hidden shadow-sm transition-colors duration-200">
<a
href="https://git.adityagupta.dev/sortedcord/brew/issues/new"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between px-4 py-3.5 hover:bg-[#F3EDE4]/50 dark:hover:bg-[#2C1810]/50 transition-colors cursor-pointer no-underline group"
>
<div className="flex items-center gap-3">
<MessageSquare size={18} strokeWidth={2} className="text-[#9C8B7A] dark:text-[#C8B9A6] flex-shrink-0" />
<div>
<div className="text-xs text-[#9C8B7A] dark:text-[#C8B9A6]">Feedback</div>
<div className="text-sm font-medium text-[#2C1810] dark:text-[#FAF6F1]">Report an issue or request a feature</div>
</div>
</div>
<span className="text-[#9C8B7A] dark:text-[#C8B9A6] text-sm group-hover:translate-x-0.5 transition-transform duration-200"></span>
</a>
</div>
</div>
</div>
);
}

View File

@@ -38,7 +38,7 @@ export default function Register({ onRegisterSuccess }) {
return (
<div className="px-5 pt-12 pb-8 max-w-[480px] mx-auto transition-colors duration-200">
<div className="mb-8 text-center">
<div className="text-5xl mb-3">🫘</div>
<img src="/icon-192.png" alt="Brew Journal" className="w-16 h-16 mx-auto mb-3 rounded-[22%]" />
<h1 className="font-serif text-3xl font-semibold text-[#2C1810] dark:text-[#FAF6F1] mb-1">Create Account</h1>
<p className="text-sm text-[#9C8B7A] dark:text-[#C8B9A6]">Start tracking your coffee journey</p>
</div>

View File

@@ -6,12 +6,28 @@ import { execSync } from 'child_process'
// Derive version from git tags; fall back to commit hash
function getGitVersion() {
try {
// If there's a tag pointing at HEAD, use it exactly (e.g. "v1.2.0")
// Otherwise use "v0.0.0-<hash>[-dirty]"
const raw = execSync('git describe --tags --always --dirty', { stdio: ['pipe', 'pipe', 'ignore'] })
// Attempt to fetch tags in case of a shallow clone (e.g. CI/CD)
try {
execSync('git fetch --tags', { stdio: 'ignore' });
} catch (e) {
// Ignore if fetch fails
}
// Only include --dirty locally (development), omit in production to prevent false "dirty" flags
const isProd = process.env.NODE_ENV === 'production' || process.env.CI;
const describeCmd = isProd ? 'git describe --tags --always' : 'git describe --tags --always --dirty';
const raw = execSync(describeCmd, { stdio: ['pipe', 'pipe', 'ignore'] })
.toString()
.trim();
// If it looks like a pure tag (no dashes after the tag part) return as-is
// If it's just a commit hash (no tags found)
if (/^[0-9a-f]{7,}$/.test(raw)) {
return `v0.0.0-${raw}`;
}
// Otherwise, return raw (e.g., v0.1.1-5-g4a9f6b6 or v1.2.0)
// Exact tag (e.g., v1.2.0)
return raw;
} catch {
return 'dev';