feat: absorb vanity-import, community, and updater from CLI
Moved from core/cli as part of repo cleanup: - cmd/vanity-import/ — Go vanity import server - cmd/community/ — community landing page - cmd/updater/ — self-update service with Angular UI Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
3e630139ad
commit
338a0a4c5e
48 changed files with 12668 additions and 0 deletions
602
cmd/community/index.html
Normal file
602
cmd/community/index.html
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lethean Community — Build Trust Through Code</title>
|
||||
<meta name="description" content="An open source community where developers earn functional trust by fixing real bugs. BugSETI by Lethean.io — SETI@home for code.">
|
||||
<link rel="canonical" href="https://lthn.community">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Lethean Community — Build Trust Through Code">
|
||||
<meta property="og:description" content="An open source community where developers earn functional trust by fixing real bugs.">
|
||||
<meta property="og:url" content="https://lthn.community">
|
||||
<meta property="og:type" content="website">
|
||||
|
||||
<!-- Tailwind CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
lethean: {
|
||||
950: '#070a0f',
|
||||
900: '#0d1117',
|
||||
800: '#161b22',
|
||||
700: '#21262d',
|
||||
600: '#30363d',
|
||||
500: '#484f58',
|
||||
400: '#8b949e',
|
||||
300: '#c9d1d9',
|
||||
200: '#e6edf3',
|
||||
},
|
||||
cyan: {
|
||||
400: '#40c1c5',
|
||||
500: '#2da8ac',
|
||||
},
|
||||
blue: {
|
||||
400: '#58a6ff',
|
||||
500: '#4A90E2',
|
||||
600: '#357ABD',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['"DM Sans"', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,300;1,9..40,400&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
background: #070a0f;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
/* Grain overlay */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Cursor glow */
|
||||
.glow-cursor {
|
||||
position: fixed;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: radial-gradient(circle, rgba(64,193,197,0.06) 0%, transparent 70%);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
/* Typing cursor blink */
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
.cursor-blink::after {
|
||||
content: '▊';
|
||||
animation: blink 1s infinite;
|
||||
color: #40c1c5;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Fade-in on scroll */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-up {
|
||||
opacity: 0;
|
||||
animation: fadeUp 0.6s ease-out forwards;
|
||||
}
|
||||
.fade-up-d1 { animation-delay: 0.1s; }
|
||||
.fade-up-d2 { animation-delay: 0.2s; }
|
||||
.fade-up-d3 { animation-delay: 0.3s; }
|
||||
.fade-up-d4 { animation-delay: 0.4s; }
|
||||
.fade-up-d5 { animation-delay: 0.5s; }
|
||||
.fade-up-d6 { animation-delay: 0.6s; }
|
||||
|
||||
/* Terminal-style section divider */
|
||||
.terminal-line::before {
|
||||
content: '$ ';
|
||||
color: #40c1c5;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Gradient border effect */
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
}
|
||||
.gradient-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, #40c1c5, #4A90E2, #40c1c5);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
/* Soft glow for hero text */
|
||||
.text-glow {
|
||||
text-shadow: 0 0 80px rgba(64,193,197,0.3), 0 0 32px rgba(64,193,197,0.1);
|
||||
}
|
||||
|
||||
/* Stats counter animation */
|
||||
@keyframes countUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Link hover effect */
|
||||
.link-underline {
|
||||
position: relative;
|
||||
}
|
||||
.link-underline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 1px;
|
||||
background: #40c1c5;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.link-underline:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased relative overflow-x-hidden">
|
||||
|
||||
<!-- Cursor glow follower -->
|
||||
<div class="glow-cursor hidden lg:block" id="glowCursor"></div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<!-- NAV -->
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<nav class="fixed top-0 inset-x-0 z-40 backdrop-blur-xl bg-lethean-950/80 border-b border-lethean-600/30">
|
||||
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2.5 group">
|
||||
<span class="text-cyan-400 font-mono text-sm font-medium tracking-tight">lthn</span>
|
||||
<span class="text-lethean-500 font-mono text-xs">/</span>
|
||||
<span class="text-lethean-300 text-sm font-medium">community</span>
|
||||
</a>
|
||||
<div class="flex items-center gap-6 text-sm">
|
||||
<a href="#how-it-works" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">How it works</a>
|
||||
<a href="#ecosystem" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">Ecosystem</a>
|
||||
<a href="https://forge.lthn.ai/core/cli" target="_blank" rel="noopener" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">GitHub</a>
|
||||
<a href="#join" class="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-md bg-cyan-400/10 text-cyan-400 border border-cyan-400/20 hover:bg-cyan-400/20 hover:border-cyan-400/30 transition-all text-sm font-medium">
|
||||
Get BugSETI
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<!-- HERO -->
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<section class="relative min-h-screen flex items-center justify-center pt-14">
|
||||
<!-- Background grid -->
|
||||
<div class="absolute inset-0" style="background-image: linear-gradient(rgba(48,54,61,0.15) 1px, transparent 1px), linear-gradient(90deg, rgba(48,54,61,0.15) 1px, transparent 1px); background-size: 64px 64px;"></div>
|
||||
<!-- Radial fade -->
|
||||
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_20%,#070a0f_70%)]"></div>
|
||||
|
||||
<div class="relative z-10 max-w-4xl mx-auto px-6 text-center">
|
||||
<!-- Badge -->
|
||||
<div class="fade-up inline-flex items-center gap-2 px-3 py-1 rounded-full bg-lethean-800/80 border border-lethean-600/40 text-xs font-mono text-lethean-400 mb-8">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></span>
|
||||
BugSETI by Lethean.io
|
||||
</div>
|
||||
|
||||
<!-- Headline -->
|
||||
<h1 class="fade-up fade-up-d1 text-5xl sm:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.08] mb-6">
|
||||
<span class="text-lethean-200">Build trust</span><br>
|
||||
<span class="text-glow text-cyan-400">through code</span>
|
||||
</h1>
|
||||
|
||||
<!-- Subheadline -->
|
||||
<p class="fade-up fade-up-d2 text-lg sm:text-xl text-lethean-400 max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
An open source community where every commit, review, and pull request
|
||||
builds your reputation. Like SETI@home, but for fixing real bugs in real projects.
|
||||
</p>
|
||||
|
||||
<!-- Terminal preview -->
|
||||
<div class="fade-up fade-up-d3 max-w-lg mx-auto mb-10">
|
||||
<div class="gradient-border rounded-lg overflow-hidden">
|
||||
<div class="bg-lethean-900 rounded-lg">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2.5 border-b border-lethean-700/50">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-lethean-600/60"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-lethean-600/60"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-lethean-600/60"></span>
|
||||
<span class="ml-3 text-xs font-mono text-lethean-500">~</span>
|
||||
</div>
|
||||
<div class="px-4 py-4 text-left font-mono text-sm leading-relaxed">
|
||||
<div class="text-lethean-400"><span class="text-cyan-400">$</span> bugseti start</div>
|
||||
<div class="text-lethean-500 mt-1">⠋ Fetching issues from 42 OSS repos...</div>
|
||||
<div class="text-green-400/80 mt-1">✓ 7 beginner-friendly issues queued</div>
|
||||
<div class="text-green-400/80">✓ AI context prepared for each issue</div>
|
||||
<div class="text-lethean-300 mt-1">Ready. Fix bugs. Build trust. <span class="cursor-blink"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTAs -->
|
||||
<div class="fade-up fade-up-d4 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<a href="#join" class="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-cyan-400 text-lethean-950 font-semibold text-sm hover:bg-cyan-400/90 transition-all shadow-lg shadow-cyan-400/10">
|
||||
Download BugSETI
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
|
||||
</a>
|
||||
<a href="https://forge.lthn.ai/core/cli" target="_blank" rel="noopener" class="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-lethean-600/50 text-lethean-300 font-medium text-sm hover:bg-lethean-800/50 hover:border-lethean-500/50 transition-all">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||
View Source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<!-- HOW IT WORKS -->
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<section id="how-it-works" class="relative py-32">
|
||||
<div class="max-w-5xl mx-auto px-6">
|
||||
|
||||
<div class="text-center mb-20">
|
||||
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">How it works</p>
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">From install to impact</h2>
|
||||
<p class="text-lethean-400 max-w-xl mx-auto">BugSETI runs in your system tray. It finds issues, prepares context, and gets out of your way. You write code. The community remembers.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<!-- Step 1 -->
|
||||
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">1</span>
|
||||
<h3 class="text-lethean-200 font-semibold">Install & connect</h3>
|
||||
</div>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed mb-4">Download BugSETI, connect your GitHub account. That's your identity in the Lethean Community — one account, everywhere.</p>
|
||||
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
|
||||
<span class="text-cyan-400/70">$</span> gh auth login<br>
|
||||
<span class="text-cyan-400/70">$</span> bugseti init
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">2</span>
|
||||
<h3 class="text-lethean-200 font-semibold">Pick an issue</h3>
|
||||
</div>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed mb-4">BugSETI scans OSS repos for beginner-friendly issues. AI prepares context — the relevant files, similar past fixes, project conventions.</p>
|
||||
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
|
||||
<span class="text-green-400/70">✓</span> 7 issues ready<br>
|
||||
<span class="text-green-400/70">✓</span> Context seeded
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">3</span>
|
||||
<h3 class="text-lethean-200 font-semibold">Fix & earn trust</h3>
|
||||
</div>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed mb-4">Submit your PR. Every merged fix, every review, every contribution — it all counts. Your track record becomes your reputation.</p>
|
||||
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
|
||||
<span class="text-green-400/70">✓</span> PR #247 merged<br>
|
||||
<span class="text-cyan-400/70">↑</span> Trust updated
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<!-- WHAT YOU GET -->
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<section class="relative py-24">
|
||||
<div class="max-w-5xl mx-auto px-6">
|
||||
|
||||
<!-- BugSETI features -->
|
||||
<div class="grid lg:grid-cols-2 gap-16 items-center mb-32">
|
||||
<div>
|
||||
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">The app</p>
|
||||
<h2 class="text-3xl font-bold text-lethean-200 mb-4">A workbench in your tray</h2>
|
||||
<p class="text-lethean-400 leading-relaxed mb-6">BugSETI lives in your system tray on macOS, Linux, and Windows. It quietly fetches issues, seeds AI context, and presents a clean workbench when you're ready to code.</p>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-cyan-400 mt-0.5 font-mono text-xs">→</span>
|
||||
<span class="text-lethean-300">Priority queue — issues ranked by your skills and interests</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-cyan-400 mt-0.5 font-mono text-xs">→</span>
|
||||
<span class="text-lethean-300">AI context seeding — relevant files and patterns, ready to go</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-cyan-400 mt-0.5 font-mono text-xs">→</span>
|
||||
<span class="text-lethean-300">One-click PR submission — fork, branch, commit, push</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-cyan-400 mt-0.5 font-mono text-xs">→</span>
|
||||
<span class="text-lethean-300">Stats tracking — streaks, repos contributed, PRs merged</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gradient-border rounded-xl overflow-hidden">
|
||||
<div class="bg-lethean-900 rounded-xl p-1">
|
||||
<!-- Mock app UI -->
|
||||
<div class="bg-lethean-800 rounded-lg overflow-hidden">
|
||||
<div class="flex items-center gap-1.5 px-3 py-2 bg-lethean-900/80 border-b border-lethean-700/30">
|
||||
<span class="w-2 h-2 rounded-full bg-red-400/40"></span>
|
||||
<span class="w-2 h-2 rounded-full bg-yellow-400/40"></span>
|
||||
<span class="w-2 h-2 rounded-full bg-green-400/40"></span>
|
||||
<span class="ml-2 text-[10px] font-mono text-lethean-500">BugSETI — Workbench</span>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<!-- Mock issue card -->
|
||||
<div class="p-3 rounded-md bg-lethean-900/60 border border-lethean-700/20">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-mono text-cyan-400/80">lodash/lodash#5821</span>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-green-400/10 text-green-400/80 border border-green-400/20">good first issue</span>
|
||||
</div>
|
||||
<p class="text-xs text-lethean-300 mb-2">Fix _.merge not handling Symbol properties</p>
|
||||
<div class="flex items-center gap-3 text-[10px] text-lethean-500">
|
||||
<span>⭐ 58.2k</span>
|
||||
<span>JavaScript</span>
|
||||
<span>Context ready</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mock issue card 2 -->
|
||||
<div class="p-3 rounded-md bg-lethean-900/30 border border-lethean-700/10">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-mono text-lethean-500">vuejs/core#9214</span>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-blue-400/10 text-blue-400/70 border border-blue-400/15">bug</span>
|
||||
</div>
|
||||
<p class="text-xs text-lethean-400 mb-2">Teleport target not updating on HMR</p>
|
||||
<div class="flex items-center gap-3 text-[10px] text-lethean-500">
|
||||
<span>⭐ 44.7k</span>
|
||||
<span>TypeScript</span>
|
||||
<span>Seeding...</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Status bar -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-lethean-700/20 text-[10px] font-mono text-lethean-500">
|
||||
<span>7 issues queued</span>
|
||||
<span class="text-cyan-400/60">♫ dapp.fm playing</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- dapp.fm teaser -->
|
||||
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
||||
<div class="order-2 lg:order-1">
|
||||
<div class="gradient-border rounded-xl overflow-hidden">
|
||||
<div class="bg-lethean-900 rounded-xl p-6">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-cyan-400/20 to-blue-500/20 flex items-center justify-center text-cyan-400">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lethean-200 font-semibold">dapp.fm</p>
|
||||
<p class="text-xs text-lethean-500">Built into BugSETI</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mini player mock -->
|
||||
<div class="bg-lethean-800/60 rounded-lg p-4 border border-lethean-700/20">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 rounded-md bg-gradient-to-br from-purple-500/30 to-cyan-400/30 flex items-center justify-center text-xs text-lethean-400">♫</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-lethean-200 truncate">It Feels So Good (Amnesia Mix)</p>
|
||||
<p class="text-[10px] text-lethean-500">The Conductor & The Cowboy</p>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-lethean-500">3:42</span>
|
||||
</div>
|
||||
<div class="h-1 bg-lethean-700/50 rounded-full overflow-hidden">
|
||||
<div class="h-full w-2/3 bg-gradient-to-r from-cyan-400/60 to-cyan-400/30 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-lethean-500 mt-3 font-mono">Zero-trust DRM · Artists keep 95–100% · ChaCha20-Poly1305</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-1 lg:order-2">
|
||||
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Built in</p>
|
||||
<h2 class="text-3xl font-bold text-lethean-200 mb-4">Music while you merge</h2>
|
||||
<p class="text-lethean-400 leading-relaxed mb-6">dapp.fm is a free music player built into BugSETI. Zero-trust DRM where the password is the license. Artists keep almost everything. No middlemen, no platform fees.</p>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed">The player is a working implementation of the Lethean protocol RFCs — encrypted, decentralised, and yours. Code, listen, contribute.</p>
|
||||
<a href="https://demo.dapp.fm" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 mt-4 text-sm text-cyan-400 hover:text-cyan-400/80 transition-colors link-underline">
|
||||
Try the demo
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<!-- ECOSYSTEM -->
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<section id="ecosystem" class="relative py-32">
|
||||
<div class="max-w-5xl mx-auto px-6">
|
||||
|
||||
<div class="text-center mb-16">
|
||||
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Ecosystem</p>
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">One identity, everywhere</h2>
|
||||
<p class="text-lethean-400 max-w-xl mx-auto">Your GitHub is your Lethean identity. One name across Web2, Web3, Handshake DNS, blockchain — verified by what you've actually done.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Card: Lethean Protocol -->
|
||||
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Protocol</div>
|
||||
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">Lethean Network</h3>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed">Privacy-first blockchain. Consent-gated networking via the UEPS protocol. Data sovereignty cryptographically enforced.</p>
|
||||
<a href="https://lt.hn" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">lt.hn →</a>
|
||||
</div>
|
||||
|
||||
<!-- Card: Handshake DNS -->
|
||||
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Identity</div>
|
||||
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">lthn/ everywhere</h3>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed">Handshake TLD, .io, .ai, .community, .eth, .tron — one name that resolves across every namespace. Your DID, decentralised.</p>
|
||||
<a href="https://hns.to" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">hns.to →</a>
|
||||
</div>
|
||||
|
||||
<!-- Card: Open Source -->
|
||||
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Foundation</div>
|
||||
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">EUPL-1.2</h3>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed">Every line is open source under the European Union Public License. 23 languages, no jurisdiction loopholes. Code stays open, forever.</p>
|
||||
<a href="https://host.uk.com/oss" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">host.uk.com/oss →</a>
|
||||
</div>
|
||||
|
||||
<!-- Card: AI Models -->
|
||||
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Coming</div>
|
||||
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">lthn.ai</h3>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed">Open source EUPL-1.2 models up to 70B parameters. High quality, embeddable transformers for the community.</p>
|
||||
<span class="inline-flex items-center gap-1 mt-3 text-xs text-lethean-500">Coming soon</span>
|
||||
</div>
|
||||
|
||||
<!-- Card: dapp.fm -->
|
||||
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Music</div>
|
||||
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">dapp.fm</h3>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed">All-in-one publishing platform. Zero-trust DRM. Artists keep 95–100%. Built on Borg encryption and LTHN rolling keys.</p>
|
||||
<a href="https://demo.dapp.fm" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">demo.dapp.fm →</a>
|
||||
</div>
|
||||
|
||||
<!-- Card: Host UK -->
|
||||
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Services</div>
|
||||
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">Host UK</h3>
|
||||
<p class="text-sm text-lethean-400 leading-relaxed">Infrastructure and services brand of the Lethean Community. Privacy-first hosting, analytics, trust verification, notifications.</p>
|
||||
<a href="https://host.uk.com" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">host.uk.com →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<!-- JOIN / DOWNLOAD -->
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<section id="join" class="relative py-32">
|
||||
<!-- Subtle gradient bg -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-cyan-400/[0.02] to-transparent"></div>
|
||||
|
||||
<div class="relative max-w-3xl mx-auto px-6 text-center">
|
||||
|
||||
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Get started</p>
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">Join the community</h2>
|
||||
<p class="text-lethean-400 max-w-lg mx-auto mb-10">Install BugSETI. Connect your GitHub. Start contributing. Every bug you fix makes open source better — and builds a trust record that's cryptographically yours.</p>
|
||||
|
||||
<!-- Download buttons -->
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 mb-12">
|
||||
<a href="https://forge.lthn.ai/core/cli/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
|
||||
<span class="text-lg">🐧</span> Linux
|
||||
</a>
|
||||
<a href="https://forge.lthn.ai/core/cli/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
|
||||
<span class="text-lg">🍎</span> macOS
|
||||
</a>
|
||||
<a href="https://forge.lthn.ai/core/cli/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
|
||||
<span class="text-lg">🪟</span> Windows
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Or just the terminal way -->
|
||||
<div class="gradient-border rounded-lg overflow-hidden max-w-md mx-auto">
|
||||
<div class="bg-lethean-900 rounded-lg px-5 py-3 font-mono text-sm text-left">
|
||||
<span class="text-lethean-500"># or build from source</span><br>
|
||||
<span class="text-cyan-400">$</span> <span class="text-lethean-300">git clone https://forge.lthn.ai/core/cli</span><br>
|
||||
<span class="text-cyan-400">$</span> <span class="text-lethean-300">cd core && go build ./cmd/bugseti</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<!-- FOOTER -->
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<footer class="border-t border-lethean-700/20 py-12">
|
||||
<div class="max-w-5xl mx-auto px-6">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm text-cyan-400">lthn</span>
|
||||
<span class="text-lethean-600 font-mono text-xs">/</span>
|
||||
<span class="text-lethean-400 text-sm">community</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 text-xs text-lethean-500">
|
||||
<a href="https://github.com/host-uk" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">GitHub</a>
|
||||
<a href="https://discord.com/invite/lethean-lthn-379876792003067906" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">Discord</a>
|
||||
<a href="https://lethean.io" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">Lethean.io</a>
|
||||
<a href="https://host.uk.com" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">Host UK</a>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-lethean-600 font-mono">
|
||||
EUPL-1.2 · Viva La OpenSource
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<!-- JS: Cursor glow + scroll animations -->
|
||||
<!-- ─────────────────────────────────────────────── -->
|
||||
<script>
|
||||
// Cursor glow follower
|
||||
const glow = document.getElementById('glowCursor');
|
||||
if (glow && window.matchMedia('(pointer: fine)').matches) {
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
glow.style.left = e.clientX + 'px';
|
||||
glow.style.top = e.clientY + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// Intersection Observer for fade-in sections
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('fade-up');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
// Observe all section headings and cards
|
||||
document.querySelectorAll('section:not(:first-of-type) h2, section:not(:first-of-type) .grid > div').forEach(el => {
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateY(24px)';
|
||||
observer.observe(el);
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
18
cmd/updater/.gitignore
vendored
Normal file
18
cmd/updater/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Go
|
||||
updater
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
*.prof
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
287
cmd/updater/LICENSE
Normal file
287
cmd/updater/LICENSE
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
EUROPEAN UNION PUBLIC LICENCE v. 1.2
|
||||
EUPL © the European Union 2007, 2016
|
||||
|
||||
This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined
|
||||
below) which is provided under the terms of this Licence. Any use of the Work,
|
||||
other than as authorised under this Licence is prohibited (to the extent such
|
||||
use is covered by a right of the copyright holder of the Work).
|
||||
|
||||
The Work is provided under the terms of this Licence when the Licensor (as
|
||||
defined below) has placed the following notice immediately following the
|
||||
copyright notice for the Work:
|
||||
|
||||
Licensed under the EUPL
|
||||
|
||||
or has expressed by any other means his willingness to license under the EUPL.
|
||||
|
||||
1. Definitions
|
||||
|
||||
In this Licence, the following terms have the following meaning:
|
||||
|
||||
- ‘The Licence’: this Licence.
|
||||
|
||||
- ‘The Original Work’: the work or software distributed or communicated by the
|
||||
Licensor under this Licence, available as Source Code and also as Executable
|
||||
Code as the case may be.
|
||||
|
||||
- ‘Derivative Works’: the works or software that could be created by the
|
||||
Licensee, based upon the Original Work or modifications thereof. This Licence
|
||||
does not define the extent of modification or dependence on the Original Work
|
||||
required in order to classify a work as a Derivative Work; this extent is
|
||||
determined by copyright law applicable in the country mentioned in Article 15.
|
||||
|
||||
- ‘The Work’: the Original Work or its Derivative Works.
|
||||
|
||||
- ‘The Source Code’: the human-readable form of the Work which is the most
|
||||
convenient for people to study and modify.
|
||||
|
||||
- ‘The Executable Code’: any code which has generally been compiled and which is
|
||||
meant to be interpreted by a computer as a program.
|
||||
|
||||
- ‘The Licensor’: the natural or legal person that distributes or communicates
|
||||
the Work under the Licence.
|
||||
|
||||
- ‘Contributor(s)’: any natural or legal person who modifies the Work under the
|
||||
Licence, or otherwise contributes to the creation of a Derivative Work.
|
||||
|
||||
- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of
|
||||
the Work under the terms of the Licence.
|
||||
|
||||
- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending,
|
||||
renting, distributing, communicating, transmitting, or otherwise making
|
||||
available, online or offline, copies of the Work or providing access to its
|
||||
essential functionalities at the disposal of any other natural or legal
|
||||
person.
|
||||
|
||||
2. Scope of the rights granted by the Licence
|
||||
|
||||
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||
sublicensable licence to do the following, for the duration of copyright vested
|
||||
in the Original Work:
|
||||
|
||||
- use the Work in any circumstance and for all usage,
|
||||
- reproduce the Work,
|
||||
- modify the Work, and make Derivative Works based upon the Work,
|
||||
- communicate to the public, including the right to make available or display
|
||||
the Work or copies thereof to the public and perform publicly, as the case may
|
||||
be, the Work,
|
||||
- distribute the Work or copies thereof,
|
||||
- lend and rent the Work or copies thereof,
|
||||
- sublicense rights in the Work or copies thereof.
|
||||
|
||||
Those rights can be exercised on any media, supports and formats, whether now
|
||||
known or later invented, as far as the applicable law permits so.
|
||||
|
||||
In the countries where moral rights apply, the Licensor waives his right to
|
||||
exercise his moral right to the extent allowed by law in order to make effective
|
||||
the licence of the economic rights here above listed.
|
||||
|
||||
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
|
||||
any patents held by the Licensor, to the extent necessary to make use of the
|
||||
rights granted on the Work under this Licence.
|
||||
|
||||
3. Communication of the Source Code
|
||||
|
||||
The Licensor may provide the Work either in its Source Code form, or as
|
||||
Executable Code. If the Work is provided as Executable Code, the Licensor
|
||||
provides in addition a machine-readable copy of the Source Code of the Work
|
||||
along with each copy of the Work that the Licensor distributes or indicates, in
|
||||
a notice following the copyright notice attached to the Work, a repository where
|
||||
the Source Code is easily and freely accessible for as long as the Licensor
|
||||
continues to distribute or communicate the Work.
|
||||
|
||||
4. Limitations on copyright
|
||||
|
||||
Nothing in this Licence is intended to deprive the Licensee of the benefits from
|
||||
any exception or limitation to the exclusive rights of the rights owners in the
|
||||
Work, of the exhaustion of those rights or of other applicable limitations
|
||||
thereto.
|
||||
|
||||
5. Obligations of the Licensee
|
||||
|
||||
The grant of the rights mentioned above is subject to some restrictions and
|
||||
obligations imposed on the Licensee. Those obligations are the following:
|
||||
|
||||
Attribution right: The Licensee shall keep intact all copyright, patent or
|
||||
trademarks notices and all notices that refer to the Licence and to the
|
||||
disclaimer of warranties. The Licensee must include a copy of such notices and a
|
||||
copy of the Licence with every copy of the Work he/she distributes or
|
||||
communicates. The Licensee must cause any Derivative Work to carry prominent
|
||||
notices stating that the Work has been modified and the date of modification.
|
||||
|
||||
Copyleft clause: If the Licensee distributes or communicates copies of the
|
||||
Original Works or Derivative Works, this Distribution or Communication will be
|
||||
done under the terms of this Licence or of a later version of this Licence
|
||||
unless the Original Work is expressly distributed only under this version of the
|
||||
Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee
|
||||
(becoming Licensor) cannot offer or impose any additional terms or conditions on
|
||||
the Work or Derivative Work that alter or restrict the terms of the Licence.
|
||||
|
||||
Compatibility clause: If the Licensee Distributes or Communicates Derivative
|
||||
Works or copies thereof based upon both the Work and another work licensed under
|
||||
a Compatible Licence, this Distribution or Communication can be done under the
|
||||
terms of this Compatible Licence. For the sake of this clause, ‘Compatible
|
||||
Licence’ refers to the licences listed in the appendix attached to this Licence.
|
||||
Should the Licensee's obligations under the Compatible Licence conflict with
|
||||
his/her obligations under this Licence, the obligations of the Compatible
|
||||
Licence shall prevail.
|
||||
|
||||
Provision of Source Code: When distributing or communicating copies of the Work,
|
||||
the Licensee will provide a machine-readable copy of the Source Code or indicate
|
||||
a repository where this Source will be easily and freely available for as long
|
||||
as the Licensee continues to distribute or communicate the Work.
|
||||
|
||||
Legal Protection: This Licence does not grant permission to use the trade names,
|
||||
trademarks, service marks, or names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the copyright notice.
|
||||
|
||||
6. Chain of Authorship
|
||||
|
||||
The original Licensor warrants that the copyright in the Original Work granted
|
||||
hereunder is owned by him/her or licensed to him/her and that he/she has the
|
||||
power and authority to grant the Licence.
|
||||
|
||||
Each Contributor warrants that the copyright in the modifications he/she brings
|
||||
to the Work are owned by him/her or licensed to him/her and that he/she has the
|
||||
power and authority to grant the Licence.
|
||||
|
||||
Each time You accept the Licence, the original Licensor and subsequent
|
||||
Contributors grant You a licence to their contributions to the Work, under the
|
||||
terms of this Licence.
|
||||
|
||||
7. Disclaimer of Warranty
|
||||
|
||||
The Work is a work in progress, which is continuously improved by numerous
|
||||
Contributors. It is not a finished work and may therefore contain defects or
|
||||
‘bugs’ inherent to this type of development.
|
||||
|
||||
For the above reason, the Work is provided under the Licence on an ‘as is’ basis
|
||||
and without warranties of any kind concerning the Work, including without
|
||||
limitation merchantability, fitness for a particular purpose, absence of defects
|
||||
or errors, accuracy, non-infringement of intellectual property rights other than
|
||||
copyright as stated in Article 6 of this Licence.
|
||||
|
||||
This disclaimer of warranty is an essential part of the Licence and a condition
|
||||
for the grant of any rights to the Work.
|
||||
|
||||
8. Disclaimer of Liability
|
||||
|
||||
Except in the cases of wilful misconduct or damages directly caused to natural
|
||||
persons, the Licensor will in no event be liable for any direct or indirect,
|
||||
material or moral, damages of any kind, arising out of the Licence or of the use
|
||||
of the Work, including without limitation, damages for loss of goodwill, work
|
||||
stoppage, computer failure or malfunction, loss of data or any commercial
|
||||
damage, even if the Licensor has been advised of the possibility of such damage.
|
||||
However, the Licensor will be liable under statutory product liability laws as
|
||||
far such laws apply to the Work.
|
||||
|
||||
9. Additional agreements
|
||||
|
||||
While distributing the Work, You may choose to conclude an additional agreement,
|
||||
defining obligations or services consistent with this Licence. However, if
|
||||
accepting obligations, You may act only on your own behalf and on your sole
|
||||
responsibility, not on behalf of the original Licensor or any other Contributor,
|
||||
and only if You agree to indemnify, defend, and hold each Contributor harmless
|
||||
for any liability incurred by, or claims asserted against such Contributor by
|
||||
the fact You have accepted any warranty or additional liability.
|
||||
|
||||
10. Acceptance of the Licence
|
||||
|
||||
The provisions of this Licence can be accepted by clicking on an icon ‘I agree’
|
||||
placed under the bottom of a window displaying the text of this Licence or by
|
||||
affirming consent in any other similar way, in accordance with the rules of
|
||||
applicable law. Clicking on that icon indicates your clear and irrevocable
|
||||
acceptance of this Licence and all of its terms and conditions.
|
||||
|
||||
Similarly, you irrevocably accept this Licence and all of its terms and
|
||||
conditions by exercising any rights granted to You by Article 2 of this Licence,
|
||||
such as the use of the Work, the creation by You of a Derivative Work or the
|
||||
Distribution or Communication by You of the Work or copies thereof.
|
||||
|
||||
11. Information to the public
|
||||
|
||||
In case of any Distribution or Communication of the Work by means of electronic
|
||||
communication by You (for example, by offering to download the Work from a
|
||||
remote location) the distribution channel or media (for example, a website) must
|
||||
at least provide to the public the information requested by the applicable law
|
||||
regarding the Licensor, the Licence and the way it may be accessible, concluded,
|
||||
stored and reproduced by the Licensee.
|
||||
|
||||
12. Termination of the Licence
|
||||
|
||||
The Licence and the rights granted hereunder will terminate automatically upon
|
||||
any breach by the Licensee of the terms of the Licence.
|
||||
|
||||
Such a termination will not terminate the licences of any person who has
|
||||
received the Work from the Licensee under the Licence, provided such persons
|
||||
remain in full compliance with the Licence.
|
||||
|
||||
13. Miscellaneous
|
||||
|
||||
Without prejudice of Article 9 above, the Licence represents the complete
|
||||
agreement between the Parties as to the Work.
|
||||
|
||||
If any provision of the Licence is invalid or unenforceable under applicable
|
||||
law, this will not affect the validity or enforceability of the Licence as a
|
||||
whole. Such provision will be construed or reformed so as necessary to make it
|
||||
valid and enforceable.
|
||||
|
||||
The European Commission may publish other linguistic versions or new versions of
|
||||
this Licence or updated versions of the Appendix, so far this is required and
|
||||
reasonable, without reducing the scope of the rights granted by the Licence. New
|
||||
versions of the Licence will be published with a unique version number.
|
||||
|
||||
All linguistic versions of this Licence, approved by the European Commission,
|
||||
have identical value. Parties can take advantage of the linguistic version of
|
||||
their choice.
|
||||
|
||||
14. Jurisdiction
|
||||
|
||||
Without prejudice to specific agreement between parties,
|
||||
|
||||
- any litigation resulting from the interpretation of this License, arising
|
||||
between the European Union institutions, bodies, offices or agencies, as a
|
||||
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
|
||||
of Justice of the European Union, as laid down in article 272 of the Treaty on
|
||||
the Functioning of the European Union,
|
||||
|
||||
- any litigation arising between other parties and resulting from the
|
||||
interpretation of this License, will be subject to the exclusive jurisdiction
|
||||
of the competent court where the Licensor resides or conducts its primary
|
||||
business.
|
||||
|
||||
15. Applicable Law
|
||||
|
||||
Without prejudice to specific agreement between parties,
|
||||
|
||||
- this Licence shall be governed by the law of the European Union Member State
|
||||
where the Licensor has his seat, resides or has his registered office,
|
||||
|
||||
- this licence shall be governed by Belgian law if the Licensor has no seat,
|
||||
residence or registered office inside a European Union Member State.
|
||||
|
||||
Appendix
|
||||
|
||||
‘Compatible Licences’ according to Article 5 EUPL are:
|
||||
|
||||
- GNU General Public License (GPL) v. 2, v. 3
|
||||
- GNU Affero General Public License (AGPL) v. 3
|
||||
- Open Software License (OSL) v. 2.1, v. 3.0
|
||||
- Eclipse Public License (EPL) v. 1.0
|
||||
- CeCILL v. 2.0, v. 2.1
|
||||
- Mozilla Public Licence (MPL) v. 2
|
||||
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
|
||||
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
|
||||
works other than software
|
||||
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
|
||||
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
|
||||
Reciprocity (LiLiQ-R+).
|
||||
|
||||
The European Commission may update this Appendix to later versions of the above
|
||||
licences without producing a new version of the EUPL, as long as they provide
|
||||
the rights granted in Article 2 of this Licence and protect the covered Source
|
||||
Code from exclusive appropriation.
|
||||
|
||||
All other changes or additions to this Appendix require the production of a new
|
||||
EUPL version.
|
||||
40
cmd/updater/Makefile
Normal file
40
cmd/updater/Makefile
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
.PHONY: build dev release-local test coverage
|
||||
|
||||
BINARY_NAME=updater
|
||||
CMD_PATH=./cmd/updater
|
||||
|
||||
# Default LDFLAGS to empty
|
||||
LDFLAGS = ""
|
||||
|
||||
# If VERSION is set, override LDFLAGS
|
||||
ifdef VERSION
|
||||
LDFLAGS = -ldflags "-X 'github.com/snider/updater.Version=$(VERSION)'"
|
||||
endif
|
||||
|
||||
.PHONY: generate
|
||||
generate:
|
||||
@echo "Generating code..."
|
||||
@go generate ./...
|
||||
|
||||
build: generate
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@cd $(CMD_PATH) && go build $(LDFLAGS)
|
||||
|
||||
dev: build
|
||||
@echo "Running $(BINARY_NAME)..."
|
||||
@$(CMD_PATH)/$(BINARY_NAME) --check-update
|
||||
|
||||
release-local:
|
||||
@echo "Running local release with GoReleaser..."
|
||||
@~/go/bin/goreleaser release --snapshot --clean
|
||||
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@go test ./...
|
||||
|
||||
coverage:
|
||||
@echo "Generating code coverage report..."
|
||||
@go test -coverprofile=coverage.out ./...
|
||||
@echo "Coverage report generated: coverage.out"
|
||||
@echo "To view in browser: go tool cover -html=coverage.out"
|
||||
@echo "To upload to Codecov, ensure you have the Codecov CLI installed (e.g., 'go install github.com/codecov/codecov-cli@latest') and run: codecov -f coverage.out"
|
||||
117
cmd/updater/README.md
Normal file
117
cmd/updater/README.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Core Element Template
|
||||
|
||||
This repository is a template for developers to create custom HTML elements for the core web3 framework. It includes a Go backend, an Angular custom element, and a full release cycle configuration.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/your-username/core-element-template.git
|
||||
```
|
||||
|
||||
2. **Install the dependencies:**
|
||||
```bash
|
||||
cd core-element-template
|
||||
go mod tidy
|
||||
cd ui
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Run the development server:**
|
||||
```bash
|
||||
go run ./cmd/demo-cli serve
|
||||
```
|
||||
This will start the Go backend and serve the Angular custom element.
|
||||
|
||||
## Building the Custom Element
|
||||
|
||||
To build the Angular custom element, run the following command:
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm run build
|
||||
```
|
||||
|
||||
This will create a single JavaScript file in the `dist` directory that you can use in any HTML page.
|
||||
|
||||
## Usage
|
||||
|
||||
To use the updater library in your Go project, you can use the `UpdateService`.
|
||||
|
||||
### GitHub-based Updates
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/snider/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := updater.UpdateServiceConfig{
|
||||
RepoURL: "https://github.com/owner/repo",
|
||||
Channel: "stable",
|
||||
CheckOnStartup: updater.CheckAndUpdateOnStartup,
|
||||
}
|
||||
|
||||
updateService, err := updater.NewUpdateService(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create update service: %v", err)
|
||||
}
|
||||
|
||||
if err := updateService.Start(); err != nil {
|
||||
fmt.Printf("Update check failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Generic HTTP Updates
|
||||
|
||||
For updates from a generic HTTP server, the server should provide a `latest.json` file at the root of the `RepoURL`. The JSON file should have the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"url": "https://your-server.com/path/to/release-asset"
|
||||
}
|
||||
```
|
||||
|
||||
You can then configure the `UpdateService` as follows:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/snider/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := updater.UpdateServiceConfig{
|
||||
RepoURL: "https://your-server.com",
|
||||
CheckOnStartup: updater.CheckAndUpdateOnStartup,
|
||||
}
|
||||
|
||||
updateService, err := updater.NewUpdateService(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create update service: %v", err)
|
||||
}
|
||||
|
||||
if err := updateService.Start(); err != nil {
|
||||
fmt.Printf("Update check failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the EUPL-1.2 License - see the [LICENSE](LICENSE) file for details.
|
||||
35
cmd/updater/build/main.go
Normal file
35
cmd/updater/build/main.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Read package.json
|
||||
data, err := os.ReadFile("package.json")
|
||||
if err != nil {
|
||||
fmt.Println("Error reading package.json, skipping version file generation.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Parse package.json
|
||||
var pkg struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
fmt.Println("Error parsing package.json, skipping version file generation.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Create the version file
|
||||
content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version)
|
||||
err = os.WriteFile("version.go", []byte(content), 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("Error writing version file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Generated version.go with version:", pkg.Version)
|
||||
}
|
||||
216
cmd/updater/cmd.go
Normal file
216
cmd/updater/cmd.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Repository configuration for updates
|
||||
const (
|
||||
repoOwner = "host-uk"
|
||||
repoName = "core"
|
||||
)
|
||||
|
||||
// Command flags
|
||||
var (
|
||||
updateChannel string
|
||||
updateForce bool
|
||||
updateCheck bool
|
||||
updateWatchPID int
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddUpdateCommands)
|
||||
}
|
||||
|
||||
// AddUpdateCommands registers the update command and subcommands.
|
||||
func AddUpdateCommands(root *cobra.Command) {
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update core CLI to the latest version",
|
||||
Long: `Update the core CLI to the latest version from GitHub releases.
|
||||
|
||||
By default, checks the 'stable' channel for tagged releases (v*.*.*)
|
||||
Use --channel=dev for the latest development build.
|
||||
|
||||
Examples:
|
||||
core update # Update to latest stable release
|
||||
core update --check # Check for updates without applying
|
||||
core update --channel=dev # Update to latest dev build
|
||||
core update --force # Force update even if already on latest`,
|
||||
RunE: runUpdate,
|
||||
}
|
||||
|
||||
updateCmd.PersistentFlags().StringVar(&updateChannel, "channel", "stable", "Release channel: stable, beta, alpha, or dev")
|
||||
updateCmd.PersistentFlags().BoolVar(&updateForce, "force", false, "Force update even if already on latest version")
|
||||
updateCmd.Flags().BoolVar(&updateCheck, "check", false, "Only check for updates, don't apply")
|
||||
updateCmd.Flags().IntVar(&updateWatchPID, "watch-pid", 0, "Internal: watch for parent PID to die then restart")
|
||||
_ = updateCmd.Flags().MarkHidden("watch-pid")
|
||||
|
||||
updateCmd.AddCommand(&cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check for available updates",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
updateCheck = true
|
||||
return runUpdate(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
root.AddCommand(updateCmd)
|
||||
}
|
||||
|
||||
func runUpdate(cmd *cobra.Command, args []string) error {
|
||||
// If we're in watch mode, wait for parent to die then restart
|
||||
if updateWatchPID > 0 {
|
||||
return watchAndRestart(updateWatchPID)
|
||||
}
|
||||
|
||||
currentVersion := cli.AppVersion
|
||||
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Current version:"), cli.ValueStyle.Render(currentVersion))
|
||||
cli.Print("%s %s/%s\n", cli.DimStyle.Render("Platform:"), runtime.GOOS, runtime.GOARCH)
|
||||
cli.Print("%s %s\n\n", cli.DimStyle.Render("Channel:"), updateChannel)
|
||||
|
||||
// Handle dev channel specially - it's a prerelease tag, not a semver channel
|
||||
if updateChannel == "dev" {
|
||||
return handleDevUpdate(currentVersion)
|
||||
}
|
||||
|
||||
// Check for newer version
|
||||
release, updateAvailable, err := CheckForNewerVersion(repoOwner, repoName, updateChannel, true)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to check for updates")
|
||||
}
|
||||
|
||||
if release == nil {
|
||||
cli.Print("%s No releases found in %s channel\n", cli.WarningStyle.Render("!"), updateChannel)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !updateAvailable && !updateForce {
|
||||
cli.Print("%s Already on latest version (%s)\n",
|
||||
cli.SuccessStyle.Render(cli.Glyph(":check:")),
|
||||
release.TagName)
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Latest version:"), cli.SuccessStyle.Render(release.TagName))
|
||||
|
||||
if updateCheck {
|
||||
if updateAvailable {
|
||||
cli.Print("\n%s Update available: %s → %s\n",
|
||||
cli.WarningStyle.Render("!"),
|
||||
currentVersion,
|
||||
release.TagName)
|
||||
cli.Print("Run %s to update\n", cli.ValueStyle.Render("core update"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Spawn watcher before applying update
|
||||
if err := spawnWatcher(); err != nil {
|
||||
// If watcher fails, continue anyway - update will still work
|
||||
cli.Print("%s Could not spawn restart watcher: %v\n", cli.DimStyle.Render("!"), err)
|
||||
}
|
||||
|
||||
// Apply update
|
||||
cli.Print("\n%s Downloading update...\n", cli.DimStyle.Render("→"))
|
||||
|
||||
downloadURL, err := GetDownloadURL(release, "")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to get download URL")
|
||||
}
|
||||
|
||||
if err := DoUpdate(downloadURL); err != nil {
|
||||
return cli.Wrap(err, "failed to apply update")
|
||||
}
|
||||
|
||||
cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName)
|
||||
cli.Print("%s Restarting...\n", cli.DimStyle.Render("→"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDevUpdate handles updates from the dev release (rolling prerelease)
|
||||
func handleDevUpdate(currentVersion string) error {
|
||||
client := NewGithubClient()
|
||||
|
||||
// Fetch the dev release directly by tag
|
||||
release, err := client.GetLatestRelease(context.TODO(), repoOwner, repoName, "beta")
|
||||
if err != nil {
|
||||
// Try fetching the "dev" tag directly
|
||||
return handleDevTagUpdate(currentVersion)
|
||||
}
|
||||
|
||||
if release == nil {
|
||||
return handleDevTagUpdate(currentVersion)
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Latest dev:"), cli.ValueStyle.Render(release.TagName))
|
||||
|
||||
if updateCheck {
|
||||
cli.Print("\nRun %s to update\n", cli.ValueStyle.Render("core update --channel=dev"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Spawn watcher before applying update
|
||||
if err := spawnWatcher(); err != nil {
|
||||
cli.Print("%s Could not spawn restart watcher: %v\n", cli.DimStyle.Render("!"), err)
|
||||
}
|
||||
|
||||
cli.Print("\n%s Downloading update...\n", cli.DimStyle.Render("→"))
|
||||
|
||||
downloadURL, err := GetDownloadURL(release, "")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to get download URL")
|
||||
}
|
||||
|
||||
if err := DoUpdate(downloadURL); err != nil {
|
||||
return cli.Wrap(err, "failed to apply update")
|
||||
}
|
||||
|
||||
cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName)
|
||||
cli.Print("%s Restarting...\n", cli.DimStyle.Render("→"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDevTagUpdate fetches the dev release using the direct tag
|
||||
func handleDevTagUpdate(currentVersion string) error {
|
||||
// Construct download URL directly for dev release
|
||||
downloadURL := fmt.Sprintf(
|
||||
"https://github.com/%s/%s/releases/download/dev/core-%s-%s",
|
||||
repoOwner, repoName, runtime.GOOS, runtime.GOARCH,
|
||||
)
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
downloadURL += ".exe"
|
||||
}
|
||||
|
||||
cli.Print("%s dev (rolling)\n", cli.DimStyle.Render("Latest:"))
|
||||
|
||||
if updateCheck {
|
||||
cli.Print("\nRun %s to update\n", cli.ValueStyle.Render("core update --channel=dev"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Spawn watcher before applying update
|
||||
if err := spawnWatcher(); err != nil {
|
||||
cli.Print("%s Could not spawn restart watcher: %v\n", cli.DimStyle.Render("!"), err)
|
||||
}
|
||||
|
||||
cli.Print("\n%s Downloading from dev release...\n", cli.DimStyle.Render("→"))
|
||||
|
||||
if err := DoUpdate(downloadURL); err != nil {
|
||||
return cli.Wrap(err, "failed to apply update")
|
||||
}
|
||||
|
||||
cli.Print("%s Updated to latest dev build\n", cli.SuccessStyle.Render(cli.Glyph(":check:")))
|
||||
cli.Print("%s Restarting...\n", cli.DimStyle.Render("→"))
|
||||
|
||||
return nil
|
||||
}
|
||||
68
cmd/updater/cmd_unix.go
Normal file
68
cmd/updater/cmd_unix.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
//go:build !windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// spawnWatcher spawns a background process that watches for the current process
|
||||
// to exit, then restarts the binary with --version to confirm the update.
|
||||
func spawnWatcher() error {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pid := os.Getpid()
|
||||
|
||||
// Spawn: core update --watch-pid=<pid>
|
||||
cmd := exec.Command(executable, "update", "--watch-pid", strconv.Itoa(pid))
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
// Detach from parent process group
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// watchAndRestart waits for the given PID to exit, then restarts the binary.
|
||||
func watchAndRestart(pid int) error {
|
||||
// Wait for the parent process to die
|
||||
for isProcessRunning(pid) {
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Small delay to ensure file handle is released
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Get executable path
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use exec to replace this process
|
||||
return syscall.Exec(executable, []string{executable, "--version"}, os.Environ())
|
||||
}
|
||||
|
||||
// isProcessRunning checks if a process with the given PID is still running.
|
||||
func isProcessRunning(pid int) bool {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// On Unix, FindProcess always succeeds, so we need to send signal 0
|
||||
// to check if the process actually exists
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
return err == nil
|
||||
}
|
||||
76
cmd/updater/cmd_windows.go
Normal file
76
cmd/updater/cmd_windows.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//go:build windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// spawnWatcher spawns a background process that watches for the current process
|
||||
// to exit, then restarts the binary with --version to confirm the update.
|
||||
func spawnWatcher() error {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pid := os.Getpid()
|
||||
|
||||
// Spawn: core update --watch-pid=<pid>
|
||||
cmd := exec.Command(executable, "update", "--watch-pid", strconv.Itoa(pid))
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
// On Windows, use CREATE_NEW_PROCESS_GROUP to detach
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// watchAndRestart waits for the given PID to exit, then restarts the binary.
|
||||
func watchAndRestart(pid int) error {
|
||||
// Wait for the parent process to die
|
||||
for {
|
||||
if !isProcessRunning(pid) {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Small delay to ensure file handle is released
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Get executable path
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// On Windows, spawn new process and exit
|
||||
cmd := exec.Command(executable, "--version")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isProcessRunning checks if a process with the given PID is still running.
|
||||
func isProcessRunning(pid int) bool {
|
||||
// On Windows, try to open the process with query rights
|
||||
handle, err := syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
syscall.CloseHandle(handle)
|
||||
return true
|
||||
}
|
||||
9
cmd/updater/docs/README.md
Normal file
9
cmd/updater/docs/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Documentation
|
||||
|
||||
Welcome to the documentation for the `updater` library. This library provides self-update functionality for Go applications, supporting both GitHub Releases and generic HTTP endpoints.
|
||||
|
||||
## Contents
|
||||
|
||||
* [Getting Started](getting-started.md): Installation and basic usage.
|
||||
* [Configuration](configuration.md): Detailed configuration options for `UpdateService` and CLI flags.
|
||||
* [Architecture](architecture.md): How the updater works, including GitHub integration and version comparison.
|
||||
53
cmd/updater/docs/architecture.md
Normal file
53
cmd/updater/docs/architecture.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Architecture
|
||||
|
||||
The `updater` library is designed to facilitate self-updates for Go applications by replacing the running binary with a newer version downloaded from a remote source.
|
||||
|
||||
## Update Mechanisms
|
||||
|
||||
The library supports two primary update sources:
|
||||
|
||||
1. **GitHub Releases:** Fetches releases directly from a GitHub repository.
|
||||
2. **Generic HTTP:** Fetches update information from a generic HTTP endpoint.
|
||||
|
||||
### GitHub Releases
|
||||
|
||||
When configured with a GitHub repository URL (e.g., `https://github.com/owner/repo`), the updater uses the GitHub API to find releases.
|
||||
|
||||
* **Channel Support:** You can specify a "channel" (e.g., "stable", "beta"). The updater will filter releases based on this channel.
|
||||
* Ideally, this maps to release tags or pre-release status (though the specific implementation details of how "channel" maps to GitHub release types should be verified in the code).
|
||||
* **Pull Request Updates:** The library supports updating to a specific pull request artifact, useful for testing pre-release builds.
|
||||
|
||||
### Generic HTTP
|
||||
|
||||
When configured with a generic HTTP URL, the updater expects the endpoint to return a JSON object describing the latest version.
|
||||
|
||||
**Expected JSON Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"url": "https://your-server.com/path/to/release-asset"
|
||||
}
|
||||
```
|
||||
|
||||
The updater compares the `version` from the JSON with the current application version. If the remote version is newer, it downloads the binary from the `url`.
|
||||
|
||||
## Version Comparison
|
||||
|
||||
The library uses Semantic Versioning (SemVer) to compare versions.
|
||||
|
||||
* **Prefix Handling:** The `ForceSemVerPrefix` configuration option allows you to standardize version tags by enforcing a `v` prefix (e.g., `v1.0.0` vs `1.0.0`) for consistent comparison.
|
||||
* **Logic:**
|
||||
* If `Remote Version` > `Current Version`: Update available.
|
||||
* If `Remote Version` <= `Current Version`: Up to date.
|
||||
|
||||
## Self-Update Process
|
||||
|
||||
The actual update process is handled by the `minio/selfupdate` library.
|
||||
|
||||
1. **Download:** The new binary is downloaded from the source.
|
||||
2. **Verification:** (Depending on configuration/implementation) Checksums may be verified.
|
||||
3. **Apply:** The current executable file is replaced with the new binary.
|
||||
* **Windows:** The old binary is renamed (often to `.old`) before replacement to allow the write operation.
|
||||
* **Linux/macOS:** The file is unlinked and replaced.
|
||||
4. **Restart:** The application usually needs to be restarted for the changes to take effect. The `updater` library currently handles the *replacement*, but the *restart* logic is typically left to the application.
|
||||
34
cmd/updater/docs/configuration.md
Normal file
34
cmd/updater/docs/configuration.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Configuration
|
||||
|
||||
The `updater` library is highly configurable via the `UpdateServiceConfig` struct.
|
||||
|
||||
## UpdateServiceConfig
|
||||
|
||||
When creating a new `UpdateService`, you pass a `UpdateServiceConfig` struct. Here are the available fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `RepoURL` | `string` | The URL to the repository for updates. Can be a GitHub repository URL (e.g., `https://github.com/owner/repo`) or a base URL for a generic HTTP update server. |
|
||||
| `Channel` | `string` | Specifies the release channel to track (e.g., "stable", "prerelease"). This is **only used for GitHub-based updates**. |
|
||||
| `CheckOnStartup` | `StartupCheckMode` | Determines the behavior when the service starts. See [Startup Modes](#startup-modes) below. |
|
||||
| `ForceSemVerPrefix` | `bool` | Toggles whether to enforce a 'v' prefix on version tags for display and comparison. If `true`, a 'v' prefix is added if missing. |
|
||||
| `ReleaseURLFormat` | `string` | A template for constructing the download URL for a release asset. The placeholder `{tag}` will be replaced with the release tag. |
|
||||
|
||||
### Startup Modes
|
||||
|
||||
The `CheckOnStartup` field can take one of the following values:
|
||||
|
||||
* `updater.NoCheck`: Disables any checks on startup.
|
||||
* `updater.CheckOnStartup`: Checks for updates on startup but does not apply them.
|
||||
* `updater.CheckAndUpdateOnStartup`: Checks for and applies updates on startup.
|
||||
|
||||
## CLI Flags
|
||||
|
||||
If you are using the example CLI provided in `cmd/updater`, the following flags are available:
|
||||
|
||||
* `--check-update`: Check for new updates without applying them.
|
||||
* `--do-update`: Perform an update if available.
|
||||
* `--channel`: Set the update channel (e.g., stable, beta, alpha). If not set, it's determined from the current version tag.
|
||||
* `--force-semver-prefix`: Force 'v' prefix on semver tags (default `true`).
|
||||
* `--release-url-format`: A URL format for release assets.
|
||||
* `--pull-request`: Update to a specific pull request (integer ID).
|
||||
85
cmd/updater/docs/getting-started.md
Normal file
85
cmd/updater/docs/getting-started.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Getting Started
|
||||
|
||||
This guide will help you integrate the `updater` library into your Go application.
|
||||
|
||||
## Installation
|
||||
|
||||
To install the library, run:
|
||||
|
||||
```bash
|
||||
go get github.com/snider/updater
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
The `updater` library provides an `UpdateService` that simplifies the process of checking for and applying updates.
|
||||
|
||||
### GitHub-based Updates
|
||||
|
||||
If you are hosting your releases on GitHub, you can configure the service to check your repository.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/snider/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Configure the update service
|
||||
config := updater.UpdateServiceConfig{
|
||||
RepoURL: "https://github.com/your-username/your-repo",
|
||||
Channel: "stable", // or "beta", "alpha", etc.
|
||||
CheckOnStartup: updater.CheckAndUpdateOnStartup,
|
||||
}
|
||||
|
||||
// Create the service
|
||||
updateService, err := updater.NewUpdateService(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create update service: %v", err)
|
||||
}
|
||||
|
||||
// Start the service (checks for updates and applies them if configured)
|
||||
if err := updateService.Start(); err != nil {
|
||||
fmt.Printf("Update check/apply failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Update check completed.")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Generic HTTP Updates
|
||||
|
||||
If you are hosting your releases on a generic HTTP server, the server must provide a way to check for the latest version.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/snider/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := updater.UpdateServiceConfig{
|
||||
RepoURL: "https://your-server.com/updates",
|
||||
CheckOnStartup: updater.CheckOnStartup, // Check only, don't apply automatically
|
||||
}
|
||||
|
||||
updateService, err := updater.NewUpdateService(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create update service: %v", err)
|
||||
}
|
||||
|
||||
if err := updateService.Start(); err != nil {
|
||||
fmt.Printf("Update check failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Generic HTTP updates, the endpoint is expected to return a JSON object with `version` and `url` fields. See [Architecture](architecture.md) for more details.
|
||||
55
cmd/updater/generic_http.go
Normal file
55
cmd/updater/generic_http.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// GenericUpdateInfo holds the information from a latest.json file.
|
||||
// This file is expected to be at the root of a generic HTTP update server.
|
||||
type GenericUpdateInfo struct {
|
||||
Version string `json:"version"` // The version number of the update.
|
||||
URL string `json:"url"` // The URL to download the update from.
|
||||
}
|
||||
|
||||
// GetLatestUpdateFromURL fetches and parses a latest.json file from a base URL.
|
||||
// The server at the baseURL should host a 'latest.json' file that contains
|
||||
// the version and download URL for the latest update.
|
||||
//
|
||||
// Example of latest.json:
|
||||
//
|
||||
// {
|
||||
// "version": "1.2.3",
|
||||
// "url": "https://your-server.com/path/to/release-asset"
|
||||
// }
|
||||
func GetLatestUpdateFromURL(baseURL string) (*GenericUpdateInfo, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||
}
|
||||
// Append latest.json to the path
|
||||
u.Path += "/latest.json"
|
||||
|
||||
resp, err := http.Get(u.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch latest.json: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch latest.json: status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info GenericUpdateInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse latest.json: %w", err)
|
||||
}
|
||||
|
||||
if info.Version == "" || info.URL == "" {
|
||||
return nil, fmt.Errorf("invalid latest.json content: version or url is missing")
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
77
cmd/updater/generic_http_test.go
Normal file
77
cmd/updater/generic_http_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetLatestUpdateFromURL(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
expectError bool
|
||||
expectedVersion string
|
||||
expectedURL string
|
||||
}{
|
||||
{
|
||||
name: "Valid latest.json",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintln(w, `{"version": "v1.1.0", "url": "http://example.com/release.zip"}`)
|
||||
},
|
||||
expectedVersion: "v1.1.0",
|
||||
expectedURL: "http://example.com/release.zip",
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintln(w, `{"version": "v1.1.0", "url": "http://example.com/release.zip"`) // Missing closing brace
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Missing version",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintln(w, `{"url": "http://example.com/release.zip"}`)
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Missing URL",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintln(w, `{"version": "v1.1.0"}`)
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Server error",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(tc.handler)
|
||||
defer server.Close()
|
||||
|
||||
info, err := GetLatestUpdateFromURL(server.URL)
|
||||
|
||||
if (err != nil) != tc.expectError {
|
||||
t.Errorf("Expected error: %v, got: %v", tc.expectError, err)
|
||||
}
|
||||
|
||||
if !tc.expectError {
|
||||
if info.Version != tc.expectedVersion {
|
||||
t.Errorf("Expected version: %s, got: %s", tc.expectedVersion, info.Version)
|
||||
}
|
||||
if info.URL != tc.expectedURL {
|
||||
t.Errorf("Expected URL: %s, got: %s", tc.expectedURL, info.URL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
302
cmd/updater/github.go
Normal file
302
cmd/updater/github.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Repo represents a repository from the GitHub API.
|
||||
type Repo struct {
|
||||
CloneURL string `json:"clone_url"` // The URL to clone the repository.
|
||||
}
|
||||
|
||||
// ReleaseAsset represents a single asset from a GitHub release.
|
||||
type ReleaseAsset struct {
|
||||
Name string `json:"name"` // The name of the asset.
|
||||
DownloadURL string `json:"browser_download_url"` // The URL to download the asset.
|
||||
}
|
||||
|
||||
// Release represents a GitHub release.
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"` // The name of the tag for the release.
|
||||
PreRelease bool `json:"prerelease"` // Indicates if the release is a pre-release.
|
||||
Assets []ReleaseAsset `json:"assets"` // A list of assets associated with the release.
|
||||
}
|
||||
|
||||
// GithubClient defines the interface for interacting with the GitHub API.
|
||||
// This allows for mocking the client in tests.
|
||||
type GithubClient interface {
|
||||
// GetPublicRepos fetches the public repositories for a user or organization.
|
||||
GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error)
|
||||
// GetLatestRelease fetches the latest release for a given repository and channel.
|
||||
GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error)
|
||||
// GetReleaseByPullRequest fetches a release associated with a specific pull request number.
|
||||
GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error)
|
||||
}
|
||||
|
||||
type githubClient struct{}
|
||||
|
||||
// NewAuthenticatedClient creates a new HTTP client that authenticates with the GitHub API.
|
||||
// It uses the GITHUB_TOKEN environment variable for authentication.
|
||||
// If the token is not set, it returns the default HTTP client.
|
||||
var NewAuthenticatedClient = func(ctx context.Context) *http.Client {
|
||||
token := os.Getenv("GITHUB_TOKEN")
|
||||
if token == "" {
|
||||
return http.DefaultClient
|
||||
}
|
||||
ts := oauth2.StaticTokenSource(
|
||||
&oauth2.Token{AccessToken: token},
|
||||
)
|
||||
return oauth2.NewClient(ctx, ts)
|
||||
}
|
||||
|
||||
func (g *githubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
|
||||
return g.getPublicReposWithAPIURL(ctx, "https://api.github.com", userOrOrg)
|
||||
}
|
||||
|
||||
func (g *githubClient) getPublicReposWithAPIURL(ctx context.Context, apiURL, userOrOrg string) ([]string, error) {
|
||||
client := NewAuthenticatedClient(ctx)
|
||||
var allCloneURLs []string
|
||||
url := fmt.Sprintf("%s/users/%s/repos", apiURL, userOrOrg)
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Borg-Data-Collector")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
// Try organization endpoint
|
||||
url = fmt.Sprintf("%s/orgs/%s/repos", apiURL, userOrOrg)
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Borg-Data-Collector")
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("failed to fetch repos: %s", resp.Status)
|
||||
}
|
||||
|
||||
var repos []Repo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
|
||||
_ = resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
for _, repo := range repos {
|
||||
allCloneURLs = append(allCloneURLs, repo.CloneURL)
|
||||
}
|
||||
|
||||
linkHeader := resp.Header.Get("Link")
|
||||
if linkHeader == "" {
|
||||
break
|
||||
}
|
||||
nextURL := g.findNextURL(linkHeader)
|
||||
if nextURL == "" {
|
||||
break
|
||||
}
|
||||
url = nextURL
|
||||
}
|
||||
|
||||
return allCloneURLs, nil
|
||||
}
|
||||
|
||||
func (g *githubClient) findNextURL(linkHeader string) string {
|
||||
links := strings.Split(linkHeader, ",")
|
||||
for _, link := range links {
|
||||
parts := strings.Split(link, ";")
|
||||
if len(parts) == 2 && strings.TrimSpace(parts[1]) == `rel="next"` {
|
||||
return strings.Trim(strings.TrimSpace(parts[0]), "<>")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetLatestRelease fetches the latest release for a given repository and channel.
|
||||
// The channel can be "stable", "beta", or "alpha".
|
||||
func (g *githubClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error) {
|
||||
client := NewAuthenticatedClient(ctx)
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Borg-Data-Collector")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch releases: %s", resp.Status)
|
||||
}
|
||||
|
||||
var releases []Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterReleases(releases, channel), nil
|
||||
}
|
||||
|
||||
// filterReleases filters releases based on the specified channel.
|
||||
func filterReleases(releases []Release, channel string) *Release {
|
||||
for _, release := range releases {
|
||||
releaseChannel := determineChannel(release.TagName, release.PreRelease)
|
||||
if releaseChannel == channel {
|
||||
return &release
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// determineChannel determines the stability channel of a release based on its tag and PreRelease flag.
|
||||
func determineChannel(tagName string, isPreRelease bool) string {
|
||||
tagLower := strings.ToLower(tagName)
|
||||
if strings.Contains(tagLower, "alpha") {
|
||||
return "alpha"
|
||||
}
|
||||
if strings.Contains(tagLower, "beta") {
|
||||
return "beta"
|
||||
}
|
||||
if isPreRelease { // A pre-release without alpha/beta is treated as beta
|
||||
return "beta"
|
||||
}
|
||||
return "stable"
|
||||
}
|
||||
|
||||
// GetReleaseByPullRequest fetches a release associated with a specific pull request number.
|
||||
func (g *githubClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
|
||||
client := NewAuthenticatedClient(ctx)
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Borg-Data-Collector")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch releases: %s", resp.Status)
|
||||
}
|
||||
|
||||
var releases []Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The pr number is included in the tag name with the format `vX.Y.Z-alpha.pr.123` or `vX.Y.Z-beta.pr.123`
|
||||
prTagSuffix := fmt.Sprintf(".pr.%d", prNumber)
|
||||
for _, release := range releases {
|
||||
if strings.Contains(release.TagName, prTagSuffix) {
|
||||
return &release, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil // No release found for the given PR number
|
||||
}
|
||||
|
||||
// GetDownloadURL finds the appropriate download URL for the current operating system and architecture.
|
||||
//
|
||||
// It supports two modes of operation:
|
||||
// 1. Using a 'releaseURLFormat' template: If 'releaseURLFormat' is provided,
|
||||
// it will be used to construct the download URL. The template can contain
|
||||
// placeholders for the release tag '{tag}', operating system '{os}', and
|
||||
// architecture '{arch}'.
|
||||
// 2. Automatic detection: If 'releaseURLFormat' is empty, the function will
|
||||
// inspect the assets of the release to find a suitable download URL. It
|
||||
// searches for an asset name that contains both the current OS and architecture
|
||||
// (e.g., "my-app-linux-amd64"). If no match is found, it falls back to
|
||||
// matching only the OS.
|
||||
//
|
||||
// Example with releaseURLFormat:
|
||||
//
|
||||
// release := &updater.Release{TagName: "v1.2.3"}
|
||||
// url, err := updater.GetDownloadURL(release, "https://example.com/downloads/{tag}/{os}/{arch}")
|
||||
// if err != nil {
|
||||
// // handle error
|
||||
// }
|
||||
// fmt.Println(url) // "https://example.com/downloads/v1.2.3/linux/amd64" (on a Linux AMD64 system)
|
||||
//
|
||||
// Example with automatic detection:
|
||||
//
|
||||
// release := &updater.Release{
|
||||
// Assets: []updater.ReleaseAsset{
|
||||
// {Name: "my-app-linux-amd64", DownloadURL: "https://example.com/download/linux-amd64"},
|
||||
// {Name: "my-app-windows-amd64", DownloadURL: "https://example.com/download/windows-amd64"},
|
||||
// },
|
||||
// }
|
||||
// url, err := updater.GetDownloadURL(release, "")
|
||||
// if err != nil {
|
||||
// // handle error
|
||||
// }
|
||||
// fmt.Println(url) // "https://example.com/download/linux-amd64" (on a Linux AMD64 system)
|
||||
func GetDownloadURL(release *Release, releaseURLFormat string) (string, error) {
|
||||
if release == nil {
|
||||
return "", fmt.Errorf("no release provided")
|
||||
}
|
||||
|
||||
if releaseURLFormat != "" {
|
||||
// Replace {tag}, {os}, and {arch} placeholders
|
||||
r := strings.NewReplacer(
|
||||
"{tag}", release.TagName,
|
||||
"{os}", runtime.GOOS,
|
||||
"{arch}", runtime.GOARCH,
|
||||
)
|
||||
return r.Replace(releaseURLFormat), nil
|
||||
}
|
||||
|
||||
osName := runtime.GOOS
|
||||
archName := runtime.GOARCH
|
||||
|
||||
for _, asset := range release.Assets {
|
||||
assetNameLower := strings.ToLower(asset.Name)
|
||||
// Match asset that contains both OS and architecture
|
||||
if strings.Contains(assetNameLower, osName) && strings.Contains(assetNameLower, archName) {
|
||||
return asset.DownloadURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for OS only if no asset matched both OS and arch
|
||||
for _, asset := range release.Assets {
|
||||
assetNameLower := strings.ToLower(asset.Name)
|
||||
if strings.Contains(assetNameLower, osName) {
|
||||
return asset.DownloadURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no suitable download asset found for %s/%s", osName, archName)
|
||||
}
|
||||
124
cmd/updater/github_test.go
Normal file
124
cmd/updater/github_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/Snider/Borg/pkg/mocks"
|
||||
)
|
||||
|
||||
func TestGetPublicRepos(t *testing.T) {
|
||||
mockClient := mocks.NewMockClient(map[string]*http.Response{
|
||||
"https://api.github.com/users/testuser/repos": {
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"clone_url": "https://github.com/testuser/repo1.git"}]`)),
|
||||
},
|
||||
"https://api.github.com/orgs/testorg/repos": {
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}, "Link": []string{`<https://api.github.com/organizations/123/repos?page=2>; rel="next"`}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"clone_url": "https://github.com/testorg/repo1.git"}]`)),
|
||||
},
|
||||
"https://api.github.com/organizations/123/repos?page=2": {
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"clone_url": "https://github.com/testorg/repo2.git"}]`)),
|
||||
},
|
||||
})
|
||||
|
||||
client := &githubClient{}
|
||||
oldClient := NewAuthenticatedClient
|
||||
NewAuthenticatedClient = func(ctx context.Context) *http.Client {
|
||||
return mockClient
|
||||
}
|
||||
defer func() {
|
||||
NewAuthenticatedClient = oldClient
|
||||
}()
|
||||
|
||||
// Test user repos
|
||||
repos, err := client.getPublicReposWithAPIURL(context.Background(), "https://api.github.com", "testuser")
|
||||
if err != nil {
|
||||
t.Fatalf("getPublicReposWithAPIURL for user failed: %v", err)
|
||||
}
|
||||
if len(repos) != 1 || repos[0] != "https://github.com/testuser/repo1.git" {
|
||||
t.Errorf("unexpected user repos: %v", repos)
|
||||
}
|
||||
|
||||
// Test org repos with pagination
|
||||
repos, err = client.getPublicReposWithAPIURL(context.Background(), "https://api.github.com", "testorg")
|
||||
if err != nil {
|
||||
t.Fatalf("getPublicReposWithAPIURL for org failed: %v", err)
|
||||
}
|
||||
if len(repos) != 2 || repos[0] != "https://github.com/testorg/repo1.git" || repos[1] != "https://github.com/testorg/repo2.git" {
|
||||
t.Errorf("unexpected org repos: %v", repos)
|
||||
}
|
||||
}
|
||||
func TestGetPublicRepos_Error(t *testing.T) {
|
||||
u, _ := url.Parse("https://api.github.com/users/testuser/repos")
|
||||
mockClient := mocks.NewMockClient(map[string]*http.Response{
|
||||
"https://api.github.com/users/testuser/repos": {
|
||||
StatusCode: http.StatusNotFound,
|
||||
Status: "404 Not Found",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString("")),
|
||||
Request: &http.Request{Method: "GET", URL: u},
|
||||
},
|
||||
"https://api.github.com/orgs/testuser/repos": {
|
||||
StatusCode: http.StatusNotFound,
|
||||
Status: "404 Not Found",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString("")),
|
||||
Request: &http.Request{Method: "GET", URL: u},
|
||||
},
|
||||
})
|
||||
expectedErr := "failed to fetch repos: 404 Not Found"
|
||||
|
||||
client := &githubClient{}
|
||||
oldClient := NewAuthenticatedClient
|
||||
NewAuthenticatedClient = func(ctx context.Context) *http.Client {
|
||||
return mockClient
|
||||
}
|
||||
defer func() {
|
||||
NewAuthenticatedClient = oldClient
|
||||
}()
|
||||
|
||||
// Test user repos
|
||||
_, err := client.getPublicReposWithAPIURL(context.Background(), "https://api.github.com", "testuser")
|
||||
if err.Error() != expectedErr {
|
||||
t.Fatalf("getPublicReposWithAPIURL for user failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindNextURL(t *testing.T) {
|
||||
client := &githubClient{}
|
||||
linkHeader := `<https://api.github.com/organizations/123/repos?page=2>; rel="next", <https://api.github.com/organizations/123/repos?page=1>; rel="prev"`
|
||||
nextURL := client.findNextURL(linkHeader)
|
||||
if nextURL != "https://api.github.com/organizations/123/repos?page=2" {
|
||||
t.Errorf("unexpected next URL: %s", nextURL)
|
||||
}
|
||||
|
||||
linkHeader = `<https://api.github.com/organizations/123/repos?page=1>; rel="prev"`
|
||||
nextURL = client.findNextURL(linkHeader)
|
||||
if nextURL != "" {
|
||||
t.Errorf("unexpected next URL: %s", nextURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthenticatedClient(t *testing.T) {
|
||||
// Test with no token
|
||||
client := NewAuthenticatedClient(context.Background())
|
||||
if client != http.DefaultClient {
|
||||
t.Errorf("expected http.DefaultClient, but got something else")
|
||||
}
|
||||
|
||||
// Test with token
|
||||
t.Setenv("GITHUB_TOKEN", "test-token")
|
||||
client = NewAuthenticatedClient(context.Background())
|
||||
if client == http.DefaultClient {
|
||||
t.Errorf("expected an authenticated client, but got http.DefaultClient")
|
||||
}
|
||||
}
|
||||
36
cmd/updater/mock_github_client_test.go
Normal file
36
cmd/updater/mock_github_client_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// MockGithubClient is a mock implementation of the GithubClient interface for testing.
|
||||
type MockGithubClient struct {
|
||||
GetLatestReleaseFunc func(ctx context.Context, owner, repo, channel string) (*Release, error)
|
||||
GetReleaseByPullRequestFunc func(ctx context.Context, owner, repo string, prNumber int) (*Release, error)
|
||||
GetPublicReposFunc func(ctx context.Context, userOrOrg string) ([]string, error)
|
||||
}
|
||||
|
||||
// GetLatestRelease mocks the GetLatestRelease method of the GithubClient interface.
|
||||
func (m *MockGithubClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error) {
|
||||
if m.GetLatestReleaseFunc != nil {
|
||||
return m.GetLatestReleaseFunc(ctx, owner, repo, channel)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetReleaseByPullRequest mocks the GetReleaseByPullRequest method of the GithubClient interface.
|
||||
func (m *MockGithubClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
|
||||
if m.GetReleaseByPullRequestFunc != nil {
|
||||
return m.GetReleaseByPullRequestFunc(ctx, owner, repo, prNumber)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetPublicRepos mocks the GetPublicRepos method of the GithubClient interface.
|
||||
func (m *MockGithubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
|
||||
if m.GetPublicReposFunc != nil {
|
||||
return m.GetPublicReposFunc(ctx, userOrOrg)
|
||||
}
|
||||
return []string{"repo1", "repo2"}, nil
|
||||
}
|
||||
4
cmd/updater/package.json
Normal file
4
cmd/updater/package.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "updater",
|
||||
"version": "1.2.3"
|
||||
}
|
||||
127
cmd/updater/service.go
Normal file
127
cmd/updater/service.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
//go:generate go run forge.lthn.ai/core/cli/cmd/updater/build
|
||||
|
||||
// Package updater provides functionality for self-updating Go applications.
|
||||
// It supports updates from GitHub releases and generic HTTP endpoints.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StartupCheckMode defines the updater's behavior on startup.
|
||||
type StartupCheckMode int
|
||||
|
||||
const (
|
||||
// NoCheck disables any checks on startup.
|
||||
NoCheck StartupCheckMode = iota
|
||||
// CheckOnStartup checks for updates on startup but does not apply them.
|
||||
CheckOnStartup
|
||||
// CheckAndUpdateOnStartup checks for and applies updates on startup.
|
||||
CheckAndUpdateOnStartup
|
||||
)
|
||||
|
||||
// UpdateServiceConfig holds the configuration for the UpdateService.
|
||||
type UpdateServiceConfig struct {
|
||||
// RepoURL is the URL to the repository for updates. It can be a GitHub
|
||||
// repository URL (e.g., "https://github.com/owner/repo") or a base URL
|
||||
// for a generic HTTP update server.
|
||||
RepoURL string
|
||||
// Channel specifies the release channel to track (e.g., "stable", "prerelease").
|
||||
// This is only used for GitHub-based updates.
|
||||
Channel string
|
||||
// CheckOnStartup determines the update behavior when the service starts.
|
||||
CheckOnStartup StartupCheckMode
|
||||
// ForceSemVerPrefix toggles whether to enforce a 'v' prefix on version tags for display.
|
||||
// If true, a 'v' prefix is added if missing. If false, it's removed if present.
|
||||
ForceSemVerPrefix bool
|
||||
// ReleaseURLFormat provides a template for constructing the download URL for a
|
||||
// release asset. The placeholder {tag} will be replaced with the release tag.
|
||||
ReleaseURLFormat string
|
||||
}
|
||||
|
||||
// UpdateService provides a configurable interface for handling application updates.
|
||||
// It can be configured to check for updates on startup and, if desired, apply
|
||||
// them automatically. The service can handle updates from both GitHub releases
|
||||
// and generic HTTP servers.
|
||||
type UpdateService struct {
|
||||
config UpdateServiceConfig
|
||||
isGitHub bool
|
||||
owner string
|
||||
repo string
|
||||
}
|
||||
|
||||
// NewUpdateService creates and configures a new UpdateService.
|
||||
// It parses the repository URL to determine if it's a GitHub repository
|
||||
// and extracts the owner and repo name.
|
||||
func NewUpdateService(config UpdateServiceConfig) (*UpdateService, error) {
|
||||
isGitHub := strings.Contains(config.RepoURL, "github.com")
|
||||
var owner, repo string
|
||||
var err error
|
||||
|
||||
if isGitHub {
|
||||
owner, repo, err = ParseRepoURL(config.RepoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse GitHub repo URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &UpdateService{
|
||||
config: config,
|
||||
isGitHub: isGitHub,
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start initiates the update check based on the service configuration.
|
||||
// It determines whether to perform a GitHub or HTTP-based update check
|
||||
// based on the RepoURL. The behavior of the check is controlled by the
|
||||
// CheckOnStartup setting in the configuration.
|
||||
func (s *UpdateService) Start() error {
|
||||
if s.isGitHub {
|
||||
return s.startGitHubCheck()
|
||||
}
|
||||
return s.startHTTPCheck()
|
||||
}
|
||||
|
||||
func (s *UpdateService) startGitHubCheck() error {
|
||||
switch s.config.CheckOnStartup {
|
||||
case NoCheck:
|
||||
return nil // Do nothing
|
||||
case CheckOnStartup:
|
||||
return CheckOnly(s.owner, s.repo, s.config.Channel, s.config.ForceSemVerPrefix, s.config.ReleaseURLFormat)
|
||||
case CheckAndUpdateOnStartup:
|
||||
return CheckForUpdates(s.owner, s.repo, s.config.Channel, s.config.ForceSemVerPrefix, s.config.ReleaseURLFormat)
|
||||
default:
|
||||
return fmt.Errorf("unknown startup check mode: %d", s.config.CheckOnStartup)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UpdateService) startHTTPCheck() error {
|
||||
switch s.config.CheckOnStartup {
|
||||
case NoCheck:
|
||||
return nil // Do nothing
|
||||
case CheckOnStartup:
|
||||
return CheckOnlyHTTP(s.config.RepoURL)
|
||||
case CheckAndUpdateOnStartup:
|
||||
return CheckForUpdatesHTTP(s.config.RepoURL)
|
||||
default:
|
||||
return fmt.Errorf("unknown startup check mode: %d", s.config.CheckOnStartup)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRepoURL extracts the owner and repository name from a GitHub URL.
|
||||
// It handles standard GitHub URL formats.
|
||||
func ParseRepoURL(repoURL string) (owner string, repo string, err error) {
|
||||
u, err := url.Parse(repoURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("invalid repo URL path: %s", u.Path)
|
||||
}
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
42
cmd/updater/service_examples_test.go
Normal file
42
cmd/updater/service_examples_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package updater_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"forge.lthn.ai/core/cli/cmd/updater"
|
||||
)
|
||||
|
||||
func ExampleNewUpdateService() {
|
||||
// Mock the update check functions to prevent actual updates during tests
|
||||
updater.CheckForUpdates = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
|
||||
fmt.Println("CheckForUpdates called")
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
updater.CheckForUpdates = nil // Restore original function
|
||||
}()
|
||||
|
||||
config := updater.UpdateServiceConfig{
|
||||
RepoURL: "https://github.com/owner/repo",
|
||||
Channel: "stable",
|
||||
CheckOnStartup: updater.CheckAndUpdateOnStartup,
|
||||
}
|
||||
updateService, err := updater.NewUpdateService(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create update service: %v", err)
|
||||
}
|
||||
if err := updateService.Start(); err != nil {
|
||||
log.Printf("Update check failed: %v", err)
|
||||
}
|
||||
// Output: CheckForUpdates called
|
||||
}
|
||||
|
||||
func ExampleParseRepoURL() {
|
||||
owner, repo, err := updater.ParseRepoURL("https://github.com/owner/repo")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse repo URL: %v", err)
|
||||
}
|
||||
fmt.Printf("Owner: %s, Repo: %s", owner, repo)
|
||||
// Output: Owner: owner, Repo: repo
|
||||
}
|
||||
170
cmd/updater/service_test.go
Normal file
170
cmd/updater/service_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewUpdateService(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
config UpdateServiceConfig
|
||||
expectError bool
|
||||
isGitHub bool
|
||||
}{
|
||||
{
|
||||
name: "Valid GitHub URL",
|
||||
config: UpdateServiceConfig{
|
||||
RepoURL: "https://github.com/owner/repo",
|
||||
},
|
||||
isGitHub: true,
|
||||
},
|
||||
{
|
||||
name: "Valid non-GitHub URL",
|
||||
config: UpdateServiceConfig{
|
||||
RepoURL: "https://example.com/updates",
|
||||
},
|
||||
isGitHub: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid GitHub URL",
|
||||
config: UpdateServiceConfig{
|
||||
RepoURL: "https://github.com/owner",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
service, err := NewUpdateService(tc.config)
|
||||
if (err != nil) != tc.expectError {
|
||||
t.Errorf("Expected error: %v, got: %v", tc.expectError, err)
|
||||
}
|
||||
if err == nil && service.isGitHub != tc.isGitHub {
|
||||
t.Errorf("Expected isGitHub: %v, got: %v", tc.isGitHub, service.isGitHub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateService_Start(t *testing.T) {
|
||||
// Setup a mock server for HTTP tests
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"version": "v1.1.0", "url": "http://example.com/release.zip"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
config UpdateServiceConfig
|
||||
checkOnlyGitHub int
|
||||
checkAndDoGitHub int
|
||||
checkOnlyHTTPCalls int
|
||||
checkAndDoHTTPCalls int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "GitHub: NoCheck",
|
||||
config: UpdateServiceConfig{
|
||||
RepoURL: "https://github.com/owner/repo",
|
||||
CheckOnStartup: NoCheck,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub: CheckOnStartup",
|
||||
config: UpdateServiceConfig{
|
||||
RepoURL: "https://github.com/owner/repo",
|
||||
CheckOnStartup: CheckOnStartup,
|
||||
},
|
||||
checkOnlyGitHub: 1,
|
||||
},
|
||||
{
|
||||
name: "GitHub: CheckAndUpdateOnStartup",
|
||||
config: UpdateServiceConfig{
|
||||
RepoURL: "https://github.com/owner/repo",
|
||||
CheckOnStartup: CheckAndUpdateOnStartup,
|
||||
},
|
||||
checkAndDoGitHub: 1,
|
||||
},
|
||||
{
|
||||
name: "HTTP: NoCheck",
|
||||
config: UpdateServiceConfig{
|
||||
RepoURL: server.URL,
|
||||
CheckOnStartup: NoCheck,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "HTTP: CheckOnStartup",
|
||||
config: UpdateServiceConfig{
|
||||
RepoURL: server.URL,
|
||||
CheckOnStartup: CheckOnStartup,
|
||||
},
|
||||
checkOnlyHTTPCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "HTTP: CheckAndUpdateOnStartup",
|
||||
config: UpdateServiceConfig{
|
||||
RepoURL: server.URL,
|
||||
CheckOnStartup: CheckAndUpdateOnStartup,
|
||||
},
|
||||
checkAndDoHTTPCalls: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var checkOnlyGitHub, checkAndDoGitHub, checkOnlyHTTP, checkAndDoHTTP int
|
||||
|
||||
// Mock GitHub functions
|
||||
originalCheckOnly := CheckOnly
|
||||
CheckOnly = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
|
||||
checkOnlyGitHub++
|
||||
return nil
|
||||
}
|
||||
defer func() { CheckOnly = originalCheckOnly }()
|
||||
|
||||
originalCheckForUpdates := CheckForUpdates
|
||||
CheckForUpdates = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
|
||||
checkAndDoGitHub++
|
||||
return nil
|
||||
}
|
||||
defer func() { CheckForUpdates = originalCheckForUpdates }()
|
||||
|
||||
// Mock HTTP functions
|
||||
originalCheckOnlyHTTP := CheckOnlyHTTP
|
||||
CheckOnlyHTTP = func(baseURL string) error {
|
||||
checkOnlyHTTP++
|
||||
return nil
|
||||
}
|
||||
defer func() { CheckOnlyHTTP = originalCheckOnlyHTTP }()
|
||||
|
||||
originalCheckForUpdatesHTTP := CheckForUpdatesHTTP
|
||||
CheckForUpdatesHTTP = func(baseURL string) error {
|
||||
checkAndDoHTTP++
|
||||
return nil
|
||||
}
|
||||
defer func() { CheckForUpdatesHTTP = originalCheckForUpdatesHTTP }()
|
||||
|
||||
service, _ := NewUpdateService(tc.config)
|
||||
err := service.Start()
|
||||
|
||||
if (err != nil) != tc.expectError {
|
||||
t.Errorf("Expected error: %v, got: %v", tc.expectError, err)
|
||||
}
|
||||
if checkOnlyGitHub != tc.checkOnlyGitHub {
|
||||
t.Errorf("Expected GitHub CheckOnly calls: %d, got: %d", tc.checkOnlyGitHub, checkOnlyGitHub)
|
||||
}
|
||||
if checkAndDoGitHub != tc.checkAndDoGitHub {
|
||||
t.Errorf("Expected GitHub CheckForUpdates calls: %d, got: %d", tc.checkAndDoGitHub, checkAndDoGitHub)
|
||||
}
|
||||
if checkOnlyHTTP != tc.checkOnlyHTTPCalls {
|
||||
t.Errorf("Expected HTTP CheckOnly calls: %d, got: %d", tc.checkOnlyHTTPCalls, checkOnlyHTTP)
|
||||
}
|
||||
if checkAndDoHTTP != tc.checkAndDoHTTPCalls {
|
||||
t.Errorf("Expected HTTP CheckForUpdates calls: %d, got: %d", tc.checkAndDoHTTPCalls, checkAndDoHTTP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
0
cmd/updater/tests.patch
Normal file
0
cmd/updater/tests.patch
Normal file
17
cmd/updater/ui/.editorconfig
Normal file
17
cmd/updater/ui/.editorconfig
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
43
cmd/updater/ui/.gitignore
vendored
Normal file
43
cmd/updater/ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
4
cmd/updater/ui/.vscode/extensions.json
vendored
Normal file
4
cmd/updater/ui/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
cmd/updater/ui/.vscode/launch.json
vendored
Normal file
20
cmd/updater/ui/.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
cmd/updater/ui/.vscode/tasks.json
vendored
Normal file
42
cmd/updater/ui/.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
59
cmd/updater/ui/README.md
Normal file
59
cmd/updater/ui/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# CoreElementTemplate
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.9.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
81
cmd/updater/ui/angular.json
Normal file
81
cmd/updater/ui/angular.json
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"core-element-template": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"standalone": false
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"standalone": false
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"standalone": false
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "none"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "core-element-template:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "core-element-template:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9037
cmd/updater/ui/package-lock.json
generated
Normal file
9037
cmd/updater/ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
49
cmd/updater/ui/package.json
Normal file
49
cmd/updater/ui/package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "core-element-template",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^20.3.0",
|
||||
"@angular/compiler": "^20.3.0",
|
||||
"@angular/core": "^20.3.0",
|
||||
"@angular/elements": "^20.3.10",
|
||||
"@angular/forms": "^20.3.0",
|
||||
"@angular/platform-browser": "^20.3.0",
|
||||
"@angular/router": "^20.3.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^20.3.9",
|
||||
"@angular/cli": "^20.3.9",
|
||||
"@angular/compiler-cli": "^20.3.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.9.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
BIN
cmd/updater/ui/public/favicon.ico
Normal file
BIN
cmd/updater/ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
23
cmd/updater/ui/src/app/app-module.ts
Normal file
23
cmd/updater/ui/src/app/app-module.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { DoBootstrap, Injector, NgModule, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { createCustomElement } from '@angular/elements';
|
||||
|
||||
import { App } from './app';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
App
|
||||
],
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners()
|
||||
]
|
||||
})
|
||||
export class AppModule implements DoBootstrap {
|
||||
constructor(private injector: Injector) {
|
||||
const el = createCustomElement(App, { injector });
|
||||
customElements.define('core-element-template', el);
|
||||
}
|
||||
|
||||
ngDoBootstrap() {}
|
||||
}
|
||||
1
cmd/updater/ui/src/app/app.html
Normal file
1
cmd/updater/ui/src/app/app.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<h1>Hello, {{ title() }}</h1>
|
||||
10
cmd/updater/ui/src/app/app.ts
Normal file
10
cmd/updater/ui/src/app/app.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Component, signal } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'core-element-template',
|
||||
templateUrl: './app.html',
|
||||
standalone: true
|
||||
})
|
||||
export class App {
|
||||
protected readonly title = signal('core-element-template');
|
||||
}
|
||||
13
cmd/updater/ui/src/index.html
Normal file
13
cmd/updater/ui/src/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>CoreElementTemplate</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<core-element-template></core-element-template>
|
||||
</body>
|
||||
</html>
|
||||
7
cmd/updater/ui/src/main.ts
Normal file
7
cmd/updater/ui/src/main.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { platformBrowser } from '@angular/platform-browser';
|
||||
import { AppModule } from './app/app-module';
|
||||
|
||||
platformBrowser().bootstrapModule(AppModule, {
|
||||
ngZoneEventCoalescing: true,
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
1
cmd/updater/ui/src/styles.css
Normal file
1
cmd/updater/ui/src/styles.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* You can add global styles to this file, and also import other style files */
|
||||
15
cmd/updater/ui/tsconfig.app.json
Normal file
15
cmd/updater/ui/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
34
cmd/updater/ui/tsconfig.json
Normal file
34
cmd/updater/ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
cmd/updater/ui/tsconfig.spec.json
Normal file
14
cmd/updater/ui/tsconfig.spec.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
237
cmd/updater/updater.go
Normal file
237
cmd/updater/updater.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/minio/selfupdate"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// Version holds the current version of the application.
|
||||
// It is set at build time via ldflags or fallback to the version in package.json.
|
||||
var Version = PkgVersion
|
||||
|
||||
// NewGithubClient is a variable that holds a function to create a new GithubClient.
|
||||
// This can be replaced in tests to inject a mock client.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// updater.NewGithubClient = func() updater.GithubClient {
|
||||
// return &mockClient{} // or your mock implementation
|
||||
// }
|
||||
var NewGithubClient = func() GithubClient {
|
||||
return &githubClient{}
|
||||
}
|
||||
|
||||
// DoUpdate is a variable that holds the function to perform the actual update.
|
||||
// This can be replaced in tests to prevent actual updates.
|
||||
var DoUpdate = func(url string) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
fmt.Printf("failed to close response body: %v\n", err)
|
||||
}
|
||||
}(resp.Body)
|
||||
|
||||
err = selfupdate.Apply(resp.Body, selfupdate.Options{})
|
||||
if err != nil {
|
||||
if rerr := selfupdate.RollbackError(err); rerr != nil {
|
||||
return fmt.Errorf("failed to rollback from failed update: %v", rerr)
|
||||
}
|
||||
return fmt.Errorf("update failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Update applied successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckForNewerVersion checks if a newer version of the application is available on GitHub.
|
||||
// It fetches the latest release for the given owner, repository, and channel, and compares its tag
|
||||
// with the current application version.
|
||||
var CheckForNewerVersion = func(owner, repo, channel string, forceSemVerPrefix bool) (*Release, bool, error) {
|
||||
client := NewGithubClient()
|
||||
ctx := context.Background()
|
||||
|
||||
release, err := client.GetLatestRelease(ctx, owner, repo, channel)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("error fetching latest release: %w", err)
|
||||
}
|
||||
|
||||
if release == nil {
|
||||
return nil, false, nil // No release found
|
||||
}
|
||||
|
||||
// Always normalize to 'v' prefix for semver comparison
|
||||
vCurrent := formatVersionForComparison(Version)
|
||||
vLatest := formatVersionForComparison(release.TagName)
|
||||
|
||||
if semver.Compare(vCurrent, vLatest) >= 0 {
|
||||
return release, false, nil // Current version is up-to-date or newer
|
||||
}
|
||||
|
||||
return release, true, nil // A newer version is available
|
||||
}
|
||||
|
||||
// CheckForUpdates checks for new updates on GitHub and applies them if a newer version is found.
|
||||
// It uses the provided owner, repository, and channel to find the latest release.
|
||||
var CheckForUpdates = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
|
||||
release, updateAvailable, err := CheckForNewerVersion(owner, repo, channel, forceSemVerPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !updateAvailable {
|
||||
if release != nil {
|
||||
fmt.Printf("Current version %s is up-to-date with latest release %s.\n",
|
||||
formatVersionForDisplay(Version, forceSemVerPrefix),
|
||||
formatVersionForDisplay(release.TagName, forceSemVerPrefix))
|
||||
} else {
|
||||
fmt.Println("No releases found.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Newer version %s found (current: %s). Applying update...\n",
|
||||
formatVersionForDisplay(release.TagName, forceSemVerPrefix),
|
||||
formatVersionForDisplay(Version, forceSemVerPrefix))
|
||||
|
||||
downloadURL, err := GetDownloadURL(release, releaseURLFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting download URL: %w", err)
|
||||
}
|
||||
|
||||
return DoUpdate(downloadURL)
|
||||
}
|
||||
|
||||
// CheckOnly checks for new updates on GitHub without applying them.
|
||||
// It prints a message indicating if a new release is available.
|
||||
var CheckOnly = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
|
||||
release, updateAvailable, err := CheckForNewerVersion(owner, repo, channel, forceSemVerPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !updateAvailable {
|
||||
if release != nil {
|
||||
fmt.Printf("Current version %s is up-to-date with latest release %s.\n",
|
||||
formatVersionForDisplay(Version, forceSemVerPrefix),
|
||||
formatVersionForDisplay(release.TagName, forceSemVerPrefix))
|
||||
} else {
|
||||
fmt.Println("No new release found.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("New release found: %s (current version: %s)\n",
|
||||
formatVersionForDisplay(release.TagName, forceSemVerPrefix),
|
||||
formatVersionForDisplay(Version, forceSemVerPrefix))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckForUpdatesByTag checks for and applies updates from GitHub based on the channel
|
||||
// determined by the current application's version tag (e.g., 'stable' or 'prerelease').
|
||||
var CheckForUpdatesByTag = func(owner, repo string) error {
|
||||
channel := determineChannel(Version, false) // isPreRelease is false for current version
|
||||
return CheckForUpdates(owner, repo, channel, true, "")
|
||||
}
|
||||
|
||||
// CheckOnlyByTag checks for updates from GitHub based on the channel determined by the
|
||||
// current version tag, without applying them.
|
||||
var CheckOnlyByTag = func(owner, repo string) error {
|
||||
channel := determineChannel(Version, false) // isPreRelease is false for current version
|
||||
return CheckOnly(owner, repo, channel, true, "")
|
||||
}
|
||||
|
||||
// CheckForUpdatesByPullRequest finds a release associated with a specific pull request number
|
||||
// on GitHub and applies the update.
|
||||
var CheckForUpdatesByPullRequest = func(owner, repo string, prNumber int, releaseURLFormat string) error {
|
||||
client := NewGithubClient()
|
||||
ctx := context.Background()
|
||||
|
||||
release, err := client.GetReleaseByPullRequest(ctx, owner, repo, prNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching release for pull request: %w", err)
|
||||
}
|
||||
|
||||
if release == nil {
|
||||
fmt.Printf("No release found for PR #%d.\n", prNumber)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Release %s found for PR #%d. Applying update...\n", release.TagName, prNumber)
|
||||
|
||||
downloadURL, err := GetDownloadURL(release, releaseURLFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting download URL: %w", err)
|
||||
}
|
||||
|
||||
return DoUpdate(downloadURL)
|
||||
}
|
||||
|
||||
// CheckForUpdatesHTTP checks for and applies updates from a generic HTTP endpoint.
|
||||
// The endpoint is expected to provide update information in a structured format.
|
||||
var CheckForUpdatesHTTP = func(baseURL string) error {
|
||||
info, err := GetLatestUpdateFromURL(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vCurrent := formatVersionForComparison(Version)
|
||||
vLatest := formatVersionForComparison(info.Version)
|
||||
|
||||
if semver.Compare(vCurrent, vLatest) >= 0 {
|
||||
fmt.Printf("Current version %s is up-to-date with latest release %s.\n", Version, info.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Newer version %s found (current: %s). Applying update...\n", info.Version, Version)
|
||||
return DoUpdate(info.URL)
|
||||
}
|
||||
|
||||
// CheckOnlyHTTP checks for updates from a generic HTTP endpoint without applying them.
|
||||
// It prints a message if a new version is available.
|
||||
var CheckOnlyHTTP = func(baseURL string) error {
|
||||
info, err := GetLatestUpdateFromURL(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vCurrent := formatVersionForComparison(Version)
|
||||
vLatest := formatVersionForComparison(info.Version)
|
||||
|
||||
if semver.Compare(vCurrent, vLatest) >= 0 {
|
||||
fmt.Printf("Current version %s is up-to-date with latest release %s.\n", Version, info.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("New release found: %s (current version: %s)\n", info.Version, Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatVersionForComparison ensures the version string has a 'v' prefix for semver comparison.
|
||||
func formatVersionForComparison(version string) string {
|
||||
if version != "" && !strings.HasPrefix(version, "v") {
|
||||
return "v" + version
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// formatVersionForDisplay ensures the version string has the correct 'v' prefix based on the forceSemVerPrefix flag.
|
||||
func formatVersionForDisplay(version string, forceSemVerPrefix bool) string {
|
||||
hasV := strings.HasPrefix(version, "v")
|
||||
if forceSemVerPrefix && !hasV {
|
||||
return "v" + version
|
||||
}
|
||||
if !forceSemVerPrefix && hasV {
|
||||
return strings.TrimPrefix(version, "v")
|
||||
}
|
||||
return version
|
||||
}
|
||||
261
cmd/updater/updater_test.go
Normal file
261
cmd/updater/updater_test.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// mockGithubClient is a mock implementation of the GithubClient interface for testing.
|
||||
type mockGithubClient struct {
|
||||
getLatestRelease func(ctx context.Context, owner, repo, channel string) (*Release, error)
|
||||
getReleaseByPR func(ctx context.Context, owner, repo string, prNumber int) (*Release, error)
|
||||
getPublicRepos func(ctx context.Context, userOrOrg string) ([]string, error)
|
||||
getLatestReleaseCount int
|
||||
getReleaseByPRCount int
|
||||
getPublicReposCount int
|
||||
}
|
||||
|
||||
func (m *mockGithubClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error) {
|
||||
m.getLatestReleaseCount++
|
||||
return m.getLatestRelease(ctx, owner, repo, channel)
|
||||
}
|
||||
|
||||
func (m *mockGithubClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
|
||||
m.getReleaseByPRCount++
|
||||
return m.getReleaseByPR(ctx, owner, repo, prNumber)
|
||||
}
|
||||
|
||||
func (m *mockGithubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
|
||||
m.getPublicReposCount++
|
||||
if m.getPublicRepos != nil {
|
||||
return m.getPublicRepos(ctx, userOrOrg)
|
||||
}
|
||||
return nil, fmt.Errorf("GetPublicRepos not implemented")
|
||||
}
|
||||
|
||||
func ExampleCheckForNewerVersion() {
|
||||
originalNewGithubClient := NewGithubClient
|
||||
defer func() { NewGithubClient = originalNewGithubClient }()
|
||||
|
||||
NewGithubClient = func() GithubClient {
|
||||
return &mockGithubClient{
|
||||
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
|
||||
return &Release{TagName: "v1.1.0"}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Version = "1.0.0"
|
||||
release, available, err := CheckForNewerVersion("owner", "repo", "stable", true)
|
||||
if err != nil {
|
||||
log.Fatalf("CheckForNewerVersion failed: %v", err)
|
||||
}
|
||||
|
||||
if available {
|
||||
fmt.Printf("Newer version available: %s", release.TagName)
|
||||
} else {
|
||||
fmt.Println("No newer version available.")
|
||||
}
|
||||
// Output: Newer version available: v1.1.0
|
||||
}
|
||||
|
||||
func ExampleCheckForUpdates() {
|
||||
// Mock the functions to prevent actual updates and network calls
|
||||
originalDoUpdate := DoUpdate
|
||||
originalNewGithubClient := NewGithubClient
|
||||
defer func() {
|
||||
DoUpdate = originalDoUpdate
|
||||
NewGithubClient = originalNewGithubClient
|
||||
}()
|
||||
|
||||
NewGithubClient = func() GithubClient {
|
||||
return &mockGithubClient{
|
||||
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
|
||||
return &Release{
|
||||
TagName: "v1.1.0",
|
||||
Assets: []ReleaseAsset{{Name: fmt.Sprintf("test-asset-%s-%s", runtime.GOOS, runtime.GOARCH), DownloadURL: "http://example.com/asset"}},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
DoUpdate = func(url string) error {
|
||||
fmt.Printf("Update would be applied from: %s", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
Version = "1.0.0"
|
||||
err := CheckForUpdates("owner", "repo", "stable", true, "")
|
||||
if err != nil {
|
||||
log.Fatalf("CheckForUpdates failed: %v", err)
|
||||
}
|
||||
// Output:
|
||||
// Newer version v1.1.0 found (current: v1.0.0). Applying update...
|
||||
// Update would be applied from: http://example.com/asset
|
||||
}
|
||||
|
||||
func ExampleCheckOnly() {
|
||||
originalNewGithubClient := NewGithubClient
|
||||
defer func() { NewGithubClient = originalNewGithubClient }()
|
||||
|
||||
NewGithubClient = func() GithubClient {
|
||||
return &mockGithubClient{
|
||||
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
|
||||
return &Release{TagName: "v1.1.0"}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Version = "1.0.0"
|
||||
err := CheckOnly("owner", "repo", "stable", true, "")
|
||||
if err != nil {
|
||||
log.Fatalf("CheckOnly failed: %v", err)
|
||||
}
|
||||
// Output: New release found: v1.1.0 (current version: v1.0.0)
|
||||
}
|
||||
|
||||
func ExampleCheckForUpdatesByTag() {
|
||||
// Mock the functions to prevent actual updates and network calls
|
||||
originalDoUpdate := DoUpdate
|
||||
originalNewGithubClient := NewGithubClient
|
||||
defer func() {
|
||||
DoUpdate = originalDoUpdate
|
||||
NewGithubClient = originalNewGithubClient
|
||||
}()
|
||||
|
||||
NewGithubClient = func() GithubClient {
|
||||
return &mockGithubClient{
|
||||
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
|
||||
if channel == "stable" {
|
||||
return &Release{
|
||||
TagName: "v1.1.0",
|
||||
Assets: []ReleaseAsset{{Name: fmt.Sprintf("test-asset-%s-%s", runtime.GOOS, runtime.GOARCH), DownloadURL: "http://example.com/asset"}},
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
DoUpdate = func(url string) error {
|
||||
fmt.Printf("Update would be applied from: %s", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
Version = "1.0.0" // A version that resolves to the "stable" channel
|
||||
err := CheckForUpdatesByTag("owner", "repo")
|
||||
if err != nil {
|
||||
log.Fatalf("CheckForUpdatesByTag failed: %v", err)
|
||||
}
|
||||
// Output:
|
||||
// Newer version v1.1.0 found (current: v1.0.0). Applying update...
|
||||
// Update would be applied from: http://example.com/asset
|
||||
}
|
||||
|
||||
func ExampleCheckOnlyByTag() {
|
||||
originalNewGithubClient := NewGithubClient
|
||||
defer func() { NewGithubClient = originalNewGithubClient }()
|
||||
|
||||
NewGithubClient = func() GithubClient {
|
||||
return &mockGithubClient{
|
||||
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
|
||||
if channel == "stable" {
|
||||
return &Release{TagName: "v1.1.0"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Version = "1.0.0" // A version that resolves to the "stable" channel
|
||||
err := CheckOnlyByTag("owner", "repo")
|
||||
if err != nil {
|
||||
log.Fatalf("CheckOnlyByTag failed: %v", err)
|
||||
}
|
||||
// Output: New release found: v1.1.0 (current version: v1.0.0)
|
||||
}
|
||||
|
||||
func ExampleCheckForUpdatesByPullRequest() {
|
||||
// Mock the functions to prevent actual updates and network calls
|
||||
originalDoUpdate := DoUpdate
|
||||
originalNewGithubClient := NewGithubClient
|
||||
defer func() {
|
||||
DoUpdate = originalDoUpdate
|
||||
NewGithubClient = originalNewGithubClient
|
||||
}()
|
||||
|
||||
NewGithubClient = func() GithubClient {
|
||||
return &mockGithubClient{
|
||||
getReleaseByPR: func(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
|
||||
if prNumber == 123 {
|
||||
return &Release{
|
||||
TagName: "v1.1.0-alpha.pr.123",
|
||||
Assets: []ReleaseAsset{{Name: fmt.Sprintf("test-asset-%s-%s", runtime.GOOS, runtime.GOARCH), DownloadURL: "http://example.com/asset-pr"}},
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
DoUpdate = func(url string) error {
|
||||
fmt.Printf("Update would be applied from: %s", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
err := CheckForUpdatesByPullRequest("owner", "repo", 123, "")
|
||||
if err != nil {
|
||||
log.Fatalf("CheckForUpdatesByPullRequest failed: %v", err)
|
||||
}
|
||||
// Output:
|
||||
// Release v1.1.0-alpha.pr.123 found for PR #123. Applying update...
|
||||
// Update would be applied from: http://example.com/asset-pr
|
||||
}
|
||||
|
||||
func ExampleCheckForUpdatesHTTP() {
|
||||
// Create a mock HTTP server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/latest.json" {
|
||||
_, _ = fmt.Fprintln(w, `{"version": "1.1.0", "url": "http://example.com/update"}`)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Mock the doUpdateFunc to prevent actual updates
|
||||
originalDoUpdate := DoUpdate
|
||||
defer func() { DoUpdate = originalDoUpdate }()
|
||||
DoUpdate = func(url string) error {
|
||||
fmt.Printf("Update would be applied from: %s", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
Version = "1.0.0"
|
||||
err := CheckForUpdatesHTTP(server.URL)
|
||||
if err != nil {
|
||||
log.Fatalf("CheckForUpdatesHTTP failed: %v", err)
|
||||
}
|
||||
// Output:
|
||||
// Newer version 1.1.0 found (current: 1.0.0). Applying update...
|
||||
// Update would be applied from: http://example.com/update
|
||||
}
|
||||
|
||||
func ExampleCheckOnlyHTTP() {
|
||||
// Create a mock HTTP server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/latest.json" {
|
||||
_, _ = fmt.Fprintln(w, `{"version": "1.1.0", "url": "http://example.com/update"}`)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
Version = "1.0.0"
|
||||
err := CheckOnlyHTTP(server.URL)
|
||||
if err != nil {
|
||||
log.Fatalf("CheckOnlyHTTP failed: %v", err)
|
||||
}
|
||||
// Output: New release found: 1.1.0 (current version: 1.0.0)
|
||||
}
|
||||
5
cmd/updater/version.go
Normal file
5
cmd/updater/version.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package updater
|
||||
|
||||
// Generated by go:generate. DO NOT EDIT.
|
||||
|
||||
const PkgVersion = "1.2.3"
|
||||
11
cmd/vanity-import/Dockerfile
Normal file
11
cmd/vanity-import/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod main.go ./
|
||||
RUN go build -trimpath -ldflags="-w -s" -o /vanity-import .
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN adduser -D -h /home/app app
|
||||
COPY --from=build /vanity-import /vanity-import
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/vanity-import"]
|
||||
3
cmd/vanity-import/go.mod
Normal file
3
cmd/vanity-import/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dappco.re/vanity-import
|
||||
|
||||
go 1.25.6
|
||||
104
cmd/vanity-import/main.go
Normal file
104
cmd/vanity-import/main.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Package main provides a Go vanity import server for dappco.re.
|
||||
//
|
||||
// When a Go tool requests ?go-get=1, this server responds with HTML
|
||||
// containing <meta name="go-import"> tags that map dappco.re module
|
||||
// paths to their Git repositories on forge.lthn.io.
|
||||
//
|
||||
// For browser requests (no ?go-get=1), it redirects to the Forgejo
|
||||
// repository web UI.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var modules = map[string]string{
|
||||
"core": "host-uk/core",
|
||||
"build": "host-uk/build",
|
||||
}
|
||||
|
||||
const (
|
||||
forgeBase = "https://forge.lthn.io"
|
||||
vanityHost = "dappco.re"
|
||||
defaultAddr = ":8080"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("ADDR")
|
||||
if addr == "" {
|
||||
addr = defaultAddr
|
||||
}
|
||||
|
||||
// Allow overriding forge base URL
|
||||
forge := os.Getenv("FORGE_URL")
|
||||
if forge == "" {
|
||||
forge = forgeBase
|
||||
}
|
||||
|
||||
// Parse additional modules from VANITY_MODULES env (format: "mod1=owner/repo,mod2=owner/repo")
|
||||
if extra := os.Getenv("VANITY_MODULES"); extra != "" {
|
||||
for _, entry := range strings.Split(extra, ",") {
|
||||
parts := strings.SplitN(strings.TrimSpace(entry), "=", 2)
|
||||
if len(parts) == 2 {
|
||||
modules[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.HandleFunc("/", handler(forge))
|
||||
|
||||
log.Printf("vanity-import listening on %s (%d modules)", addr, len(modules))
|
||||
for mod, repo := range modules {
|
||||
log.Printf(" %s/%s → %s/%s.git", vanityHost, mod, forge, repo)
|
||||
}
|
||||
log.Fatal(http.ListenAndServe(addr, nil))
|
||||
}
|
||||
|
||||
func handler(forge string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract the first path segment as the module name
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
// Root request — redirect to forge org page
|
||||
http.Redirect(w, r, forge+"/host-uk", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Module is the first path segment (e.g., "core" from "/core/pkg/mcp")
|
||||
mod := strings.SplitN(path, "/", 2)[0]
|
||||
|
||||
repo, ok := modules[mod]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// If go-get=1, serve the vanity import HTML
|
||||
if r.URL.Query().Get("go-get") == "1" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="go-import" content="%s/%s git %s/%s.git">
|
||||
<meta name="go-source" content="%s/%s %s/%s %s/%s/src/branch/main{/dir} %s/%s/src/branch/main{/dir}/{file}#L{line}">
|
||||
<meta http-equiv="refresh" content="0; url=%s/%s">
|
||||
</head>
|
||||
<body>
|
||||
Redirecting to <a href="%s/%s">%s/%s</a>...
|
||||
</body>
|
||||
</html>
|
||||
`, vanityHost, mod, forge, repo,
|
||||
vanityHost, mod, forge, repo, forge, repo, forge, repo,
|
||||
forge, repo,
|
||||
forge, repo, forge, repo)
|
||||
return
|
||||
}
|
||||
|
||||
// Browser request — redirect to Forgejo
|
||||
http.Redirect(w, r, forge+"/"+repo, http.StatusFound)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue