refactor: move core-app, updater, vanity-import, community out of CLI
- cmd/core-app/ → core framework repo (workspace module) - cmd/updater/ → go-devops - cmd/vanity-import/ → go-devops - cmd/community/ → go-devops - Remove stale Taskfile tasks for moved products (ide, app, bugseti) Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
1b99ea22f1
commit
a9fe9fe04b
118 changed files with 1 additions and 23261 deletions
95
Taskfile.yml
95
Taskfile.yml
|
|
@ -148,101 +148,6 @@ tasks:
|
|||
- task: test
|
||||
- task: review
|
||||
|
||||
# --- i18n ---
|
||||
i18n:generate:
|
||||
desc: "Regenerate i18n key constants"
|
||||
cmds:
|
||||
- go generate ./pkg/i18n/...
|
||||
|
||||
i18n:validate:
|
||||
desc: "Validate i18n key usage"
|
||||
cmds:
|
||||
- go run ./internal/tools/i18n-validate ./...
|
||||
|
||||
# --- Core IDE (Wails v3) ---
|
||||
ide:dev:
|
||||
desc: "Run Core IDE in Wails dev mode"
|
||||
dir: cmd/core-ide
|
||||
cmds:
|
||||
- cd frontend && npm install && npm run build
|
||||
- wails3 dev
|
||||
|
||||
ide:build:
|
||||
desc: "Build Core IDE production binary"
|
||||
dir: cmd/core-ide
|
||||
cmds:
|
||||
- cd frontend && npm install && npm run build
|
||||
- wails3 build
|
||||
|
||||
ide:frontend:
|
||||
desc: "Build Core IDE frontend only"
|
||||
dir: cmd/core-ide/frontend
|
||||
cmds:
|
||||
- npm install
|
||||
- npm run build
|
||||
|
||||
# --- Core App (FrankenPHP + Wails v3) ---
|
||||
app:setup:
|
||||
desc: "Install PHP-ZTS build dependency for Core App"
|
||||
cmds:
|
||||
- brew tap shivammathur/php 2>/dev/null || true
|
||||
- brew install shivammathur/php/php@8.4-zts
|
||||
|
||||
app:composer:
|
||||
desc: "Install Laravel dependencies for Core App"
|
||||
dir: cmd/core-app/laravel
|
||||
cmds:
|
||||
- composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
app:build:
|
||||
desc: "Build Core App (FrankenPHP + Laravel desktop binary)"
|
||||
dir: cmd/core-app
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
CGO_CFLAGS:
|
||||
sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes
|
||||
CGO_LDFLAGS:
|
||||
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)"
|
||||
cmds:
|
||||
- go build -tags nowatcher -o ../../bin/core-app .
|
||||
|
||||
app:dev:
|
||||
desc: "Build and run Core App"
|
||||
dir: cmd/core-app
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
CGO_CFLAGS:
|
||||
sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes
|
||||
CGO_LDFLAGS:
|
||||
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)"
|
||||
DYLD_LIBRARY_PATH: "/opt/homebrew/opt/php@8.4-zts/lib"
|
||||
cmds:
|
||||
- go build -tags nowatcher -o ../../bin/core-app .
|
||||
- ../../bin/core-app
|
||||
|
||||
# --- BugSETI (Wails v3 System Tray) ---
|
||||
bugseti:dev:
|
||||
desc: "Build and run BugSETI (production binary with embedded frontend)"
|
||||
dir: cmd/bugseti
|
||||
cmds:
|
||||
- cd frontend && npm install && npm run build
|
||||
- go build -buildvcs=false -o ../../bin/bugseti .
|
||||
- ../../bin/bugseti
|
||||
|
||||
bugseti:build:
|
||||
desc: "Build BugSETI production binary"
|
||||
dir: cmd/bugseti
|
||||
cmds:
|
||||
- cd frontend && npm install && npm run build
|
||||
- go build -trimpath -buildvcs=false -ldflags="-w -s" -o ../../bin/bugseti .
|
||||
|
||||
bugseti:frontend:
|
||||
desc: "Build BugSETI frontend only"
|
||||
dir: cmd/bugseti/frontend
|
||||
cmds:
|
||||
- npm install
|
||||
- npm run build
|
||||
|
||||
# --- Multi-repo (when in workspace) ---
|
||||
dev:health:
|
||||
desc: "Check health of all repos"
|
||||
|
|
|
|||
|
|
@ -1,602 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
# Codex Task: Core App — FrankenPHP Native Desktop App
|
||||
|
||||
## Context
|
||||
|
||||
You are working on `cmd/core-app/` inside the `host-uk/core` Go monorepo. This is a **working** native desktop application that embeds the PHP runtime (FrankenPHP) inside a Wails v3 window. A single 53MB binary runs Laravel 12 with Livewire 4, Octane worker mode, and SQLite — no Docker, no php-fpm, no nginx, no external dependencies.
|
||||
|
||||
**It already builds and runs.** Your job is to refine, not rebuild.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Wails v3 WebView (native window)
|
||||
|
|
||||
| AssetOptions.Handler → http.Handler
|
||||
v
|
||||
FrankenPHP (CGO, PHP 8.4 ZTS runtime)
|
||||
|
|
||||
| ServeHTTP() → Laravel public/index.php
|
||||
v
|
||||
Laravel 12 (Octane worker mode, 2 workers)
|
||||
├── Livewire 4 (server-rendered reactivity)
|
||||
├── SQLite (~/Library/Application Support/core-app/)
|
||||
└── Native Bridge (localhost HTTP API for PHP→Go calls)
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `main.go` | Wails app entry, system tray, window config |
|
||||
| `handler.go` | PHPHandler — FrankenPHP init, Octane worker mode, try_files URL resolution |
|
||||
| `embed.go` | `//go:embed all:laravel` + extraction to temp dir |
|
||||
| `env.go` | Persistent data dir, .env generation, APP_KEY management |
|
||||
| `app_service.go` | Wails service bindings (version, data dir, window management) |
|
||||
| `native_bridge.go` | PHP→Go HTTP bridge on localhost (random port) |
|
||||
| `laravel/` | Full Laravel 12 skeleton (vendor excluded from git, built via `composer install`) |
|
||||
|
||||
## Build Requirements
|
||||
|
||||
- **PHP 8.4 ZTS**: `brew install shivammathur/php/php@8.4-zts`
|
||||
- **Go 1.25+** with CGO enabled
|
||||
- **Build tags**: `-tags nowatcher` (FrankenPHP's watcher needs libwatcher-c, skip it)
|
||||
- **ZTS php-config**: Must use `/opt/homebrew/opt/php@8.4-zts/bin/php-config` (NOT the default php-config which may point to non-ZTS PHP)
|
||||
|
||||
```bash
|
||||
# Install Laravel deps (one-time)
|
||||
cd laravel && composer install --no-dev --optimize-autoloader
|
||||
|
||||
# Build
|
||||
ZTS_PHP_CONFIG=/opt/homebrew/opt/php@8.4-zts/bin/php-config
|
||||
CGO_ENABLED=1 \
|
||||
CGO_CFLAGS="$($ZTS_PHP_CONFIG --includes)" \
|
||||
CGO_LDFLAGS="-L/opt/homebrew/opt/php@8.4-zts/lib $($ZTS_PHP_CONFIG --ldflags) $($ZTS_PHP_CONFIG --libs)" \
|
||||
go build -tags nowatcher -o ../../bin/core-app .
|
||||
```
|
||||
|
||||
## Known Patterns & Gotchas
|
||||
|
||||
1. **FrankenPHP can't serve from embed.FS** — must extract to temp dir, symlink `storage/` to persistent data dir
|
||||
2. **WithWorkers API (v1.5.0)**: `WithWorkers(name, fileName string, num int, env map[string]string, watch []string)` — 5 positional args, NOT variadic
|
||||
3. **Worker mode needs Octane**: Workers point at `vendor/laravel/octane/bin/frankenphp-worker.php` with `APP_BASE_PATH` and `FRANKENPHP_WORKER=1` env vars
|
||||
4. **Paths with spaces**: macOS `~/Library/Application Support/` has a space — ALL .env values with paths MUST be quoted
|
||||
5. **URL resolution**: FrankenPHP doesn't auto-resolve `/` → `/index.php` — the Go handler implements try_files logic
|
||||
6. **Auto-migration**: `AppServiceProvider::boot()` runs `migrate --force` wrapped in try/catch (must not fail during composer operations)
|
||||
7. **Vendor dir**: Excluded from git (`.gitignore`), built at dev time via `composer install`, embedded by `//go:embed all:laravel` at build time
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- **UK English**: colour, organisation, centre
|
||||
- **PHP**: `declare(strict_types=1)` in every file, full type hints, PSR-12 via Pint
|
||||
- **Go**: Standard Go conventions, error wrapping with `fmt.Errorf("context: %w", err)`
|
||||
- **License**: EUPL-1.2
|
||||
- **Testing**: Pest syntax for PHP (not PHPUnit)
|
||||
|
||||
## Tasks for Codex
|
||||
|
||||
### Priority 1: Code Quality
|
||||
- [ ] Review all Go files for error handling consistency
|
||||
- [ ] Ensure handler.go's try_files logic handles edge cases (double slashes, encoded paths, path traversal)
|
||||
- [ ] Add Go tests for PHPHandler URL resolution (unit tests, no FrankenPHP needed)
|
||||
- [ ] Add Go tests for env.go (resolveDataDir, writeEnvFile, loadOrGenerateAppKey)
|
||||
|
||||
### Priority 2: Laravel Polish
|
||||
- [ ] Add `config/octane.php` with FrankenPHP server config
|
||||
- [ ] Update welcome view to show migration status (table count from SQLite)
|
||||
- [ ] Add a second Livewire component (e.g., todo list) to prove full CRUD with SQLite
|
||||
- [ ] Add proper error page views (404, 500) styled to match the dark theme
|
||||
|
||||
### Priority 3: Build Hardening
|
||||
- [ ] Verify the Taskfile.yml tasks work end-to-end (`task app:setup && task app:composer && task app:build`)
|
||||
- [ ] Add `.gitignore` entries for build artifacts (`bin/core-app`, temp dirs)
|
||||
- [ ] Ensure `go.work` and `go.mod` are consistent
|
||||
|
||||
## CRITICAL WARNINGS
|
||||
|
||||
- **DO NOT push to GitHub** — GitHub remotes have been removed deliberately. The host-uk org is flagged.
|
||||
- **DO NOT add GitHub as a remote** — Forge (forge.lthn.io / git.lthn.ai) is the source of truth.
|
||||
- **DO NOT modify files outside `cmd/core-app/`** — This is a workspace module, keep changes scoped.
|
||||
- **DO NOT remove the `-tags nowatcher` build flag** — It will fail without libwatcher-c.
|
||||
- **DO NOT change the PHP-ZTS path** — It must be the ZTS variant, not the default Homebrew PHP.
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
version: '3'
|
||||
|
||||
vars:
|
||||
PHP_CONFIG: /opt/homebrew/opt/php@8.4-zts/bin/php-config
|
||||
CGO_CFLAGS:
|
||||
sh: "{{.PHP_CONFIG}} --includes"
|
||||
CGO_LDFLAGS:
|
||||
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $({{.PHP_CONFIG}} --ldflags) $({{.PHP_CONFIG}} --libs)"
|
||||
|
||||
tasks:
|
||||
setup:
|
||||
desc: "Install PHP-ZTS build dependency"
|
||||
cmds:
|
||||
- brew tap shivammathur/php 2>/dev/null || true
|
||||
- brew install shivammathur/php/php@8.4-zts
|
||||
|
||||
build:
|
||||
desc: "Build core-app binary"
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
CGO_CFLAGS: "{{.CGO_CFLAGS}}"
|
||||
CGO_LDFLAGS: "{{.CGO_LDFLAGS}}"
|
||||
cmds:
|
||||
- go build -tags nowatcher -o ../../bin/core-app .
|
||||
|
||||
dev:
|
||||
desc: "Build and run core-app"
|
||||
deps: [build]
|
||||
env:
|
||||
DYLD_LIBRARY_PATH: "/opt/homebrew/opt/php@8.4-zts/lib"
|
||||
cmds:
|
||||
- ../../bin/core-app
|
||||
|
||||
clean:
|
||||
desc: "Remove build artifacts"
|
||||
cmds:
|
||||
- rm -f ../../bin/core-app
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// AppService provides native desktop capabilities to the Wails frontend.
|
||||
// These methods are callable via window.go.main.AppService.{Method}()
|
||||
// from any JavaScript/webview context.
|
||||
type AppService struct {
|
||||
app *application.App
|
||||
env *AppEnvironment
|
||||
}
|
||||
|
||||
func NewAppService(env *AppEnvironment) *AppService {
|
||||
return &AppService{env: env}
|
||||
}
|
||||
|
||||
// ServiceStartup is called by Wails when the application starts.
|
||||
func (s *AppService) ServiceStartup(app *application.App) {
|
||||
s.app = app
|
||||
}
|
||||
|
||||
// GetVersion returns the application version.
|
||||
func (s *AppService) GetVersion() string {
|
||||
return "0.1.0"
|
||||
}
|
||||
|
||||
// GetDataDir returns the persistent data directory path.
|
||||
func (s *AppService) GetDataDir() string {
|
||||
return s.env.DataDir
|
||||
}
|
||||
|
||||
// GetDatabasePath returns the SQLite database file path.
|
||||
func (s *AppService) GetDatabasePath() string {
|
||||
return s.env.DatabasePath
|
||||
}
|
||||
|
||||
// ShowWindow shows and focuses the main application window.
|
||||
func (s *AppService) ShowWindow(name string) {
|
||||
if s.app == nil {
|
||||
return
|
||||
}
|
||||
if w, ok := s.app.Window.Get(name); ok {
|
||||
w.Show()
|
||||
w.Focus()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
//go:embed all:laravel
|
||||
var laravelFiles embed.FS
|
||||
|
||||
// extractLaravel copies the embedded Laravel app to a temporary directory.
|
||||
// FrankenPHP needs real filesystem paths — it cannot serve from embed.FS.
|
||||
// Returns the path to the extracted Laravel root.
|
||||
func extractLaravel() (string, error) {
|
||||
tmpDir, err := os.MkdirTemp("", "core-app-laravel-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
|
||||
err = fs.WalkDir(laravelFiles, "laravel", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel("laravel", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(tmpDir, relPath)
|
||||
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(targetPath, 0o755)
|
||||
}
|
||||
|
||||
data, err := laravelFiles.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded %s: %w", path, err)
|
||||
}
|
||||
|
||||
return os.WriteFile(targetPath, data, 0o644)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", fmt.Errorf("extract Laravel: %w", err)
|
||||
}
|
||||
|
||||
return tmpDir, nil
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// AppEnvironment holds the resolved paths for the running application.
|
||||
type AppEnvironment struct {
|
||||
// DataDir is the persistent data directory (survives app updates).
|
||||
DataDir string
|
||||
// LaravelRoot is the extracted Laravel app in the temp directory.
|
||||
LaravelRoot string
|
||||
// DatabasePath is the full path to the SQLite database file.
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
// PrepareEnvironment creates data directories, generates .env, and symlinks
|
||||
// storage so Laravel can write to persistent locations.
|
||||
func PrepareEnvironment(laravelRoot string) (*AppEnvironment, error) {
|
||||
dataDir, err := resolveDataDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve data dir: %w", err)
|
||||
}
|
||||
|
||||
env := &AppEnvironment{
|
||||
DataDir: dataDir,
|
||||
LaravelRoot: laravelRoot,
|
||||
DatabasePath: filepath.Join(dataDir, "core-app.sqlite"),
|
||||
}
|
||||
|
||||
// Create persistent directories
|
||||
dirs := []string{
|
||||
dataDir,
|
||||
filepath.Join(dataDir, "storage", "app"),
|
||||
filepath.Join(dataDir, "storage", "framework", "cache", "data"),
|
||||
filepath.Join(dataDir, "storage", "framework", "sessions"),
|
||||
filepath.Join(dataDir, "storage", "framework", "views"),
|
||||
filepath.Join(dataDir, "storage", "logs"),
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create dir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create empty SQLite database if it doesn't exist
|
||||
if _, err := os.Stat(env.DatabasePath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(env.DatabasePath, nil, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("create database: %w", err)
|
||||
}
|
||||
log.Printf("Created new database: %s", env.DatabasePath)
|
||||
}
|
||||
|
||||
// Replace the extracted storage/ with a symlink to the persistent one
|
||||
extractedStorage := filepath.Join(laravelRoot, "storage")
|
||||
os.RemoveAll(extractedStorage)
|
||||
persistentStorage := filepath.Join(dataDir, "storage")
|
||||
if err := os.Symlink(persistentStorage, extractedStorage); err != nil {
|
||||
return nil, fmt.Errorf("symlink storage: %w", err)
|
||||
}
|
||||
|
||||
// Generate .env file with resolved paths
|
||||
if err := writeEnvFile(laravelRoot, env); err != nil {
|
||||
return nil, fmt.Errorf("write .env: %w", err)
|
||||
}
|
||||
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// resolveDataDir returns the OS-appropriate persistent data directory.
|
||||
func resolveDataDir() (string, error) {
|
||||
var base string
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base = filepath.Join(home, "Library", "Application Support", "core-app")
|
||||
case "linux":
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
base = filepath.Join(xdg, "core-app")
|
||||
} else {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base = filepath.Join(home, ".local", "share", "core-app")
|
||||
}
|
||||
default:
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base = filepath.Join(home, ".core-app")
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// writeEnvFile generates the Laravel .env with resolved runtime paths.
|
||||
func writeEnvFile(laravelRoot string, env *AppEnvironment) error {
|
||||
appKey, err := loadOrGenerateAppKey(env.DataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app key: %w", err)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(`APP_NAME="Core App"
|
||||
APP_ENV=production
|
||||
APP_KEY=%s
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE="%s"
|
||||
|
||||
CACHE_STORE=file
|
||||
SESSION_DRIVER=file
|
||||
LOG_CHANNEL=single
|
||||
LOG_LEVEL=warning
|
||||
|
||||
`, appKey, env.DatabasePath)
|
||||
|
||||
return os.WriteFile(filepath.Join(laravelRoot, ".env"), []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// loadOrGenerateAppKey loads an existing APP_KEY from the data dir,
|
||||
// or generates a new one and persists it.
|
||||
func loadOrGenerateAppKey(dataDir string) (string, error) {
|
||||
keyFile := filepath.Join(dataDir, ".app-key")
|
||||
|
||||
data, err := os.ReadFile(keyFile)
|
||||
if err == nil && len(data) > 0 {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Generate a new 32-byte key
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return "", fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
appKey := "base64:" + base64.StdEncoding.EncodeToString(key)
|
||||
|
||||
if err := os.WriteFile(keyFile, []byte(appKey), 0o600); err != nil {
|
||||
return "", fmt.Errorf("save key: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Generated new APP_KEY (saved to %s)", keyFile)
|
||||
return appKey, nil
|
||||
}
|
||||
|
||||
// appendEnv appends a key=value pair to the Laravel .env file.
|
||||
func appendEnv(laravelRoot, key, value string) error {
|
||||
envFile := filepath.Join(laravelRoot, ".env")
|
||||
f, err := os.OpenFile(envFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprintf(f, "%s=\"%s\"\n", key, value)
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
module forge.lthn.ai/core/go/cmd/core-app
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/dunglas/frankenphp v1.11.2
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dunglas/mercure v0.21.8 // indirect
|
||||
github.com/dunglas/skipfilter v1.0.0 // indirect
|
||||
github.com/e-dant/watcher v0.0.0-20260202035023-10268e78355f // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gofrs/uuid/v5 v5.4.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/handlers v1.5.2 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/lmittmann/tint v1.1.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/maypok86/otter/v2 v2.3.0 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/cors v1.11.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/unrolled/secure v1.17.0 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace forge.lthn.ai/core/go => ../..
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 h1:1yw6O62BReQ+uA1oyk9XaQTvLhcoHWmoQAgXmDFXpIY=
|
||||
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145/go.mod h1:877WBceefKn14QwVVn4xRFUsHsZb9clICgdeTj4XsUg=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dunglas/frankenphp v1.11.2 h1:EmigvWr7zH192r4RJQhAmEnEcP7Gjl7FX2PY9Hi4/j4=
|
||||
github.com/dunglas/frankenphp v1.11.2/go.mod h1:8rGuTpgIFerStA3dhh1CM8MjxqIJ8uMdwT59Sfhp+Lw=
|
||||
github.com/dunglas/mercure v0.21.8 h1:D+SxSq0VqdB29lfMXrsvDkFvq/cTL94aKCC0R4heKV0=
|
||||
github.com/dunglas/mercure v0.21.8/go.mod h1:kt4RJpixJOcPN+x9Z53VBhpJYSdyEEzuu9/99vJIocQ=
|
||||
github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYWPF4=
|
||||
github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w=
|
||||
github.com/e-dant/watcher v0.0.0-20260202035023-10268e78355f h1:UDB5nhFRW7IOOpLk/eP1UGj7URmPimFGV+01/EG9qR8=
|
||||
github.com/e-dant/watcher v0.0.0-20260202035023-10268e78355f/go.mod h1:PmV4IVmBJVqT2NcfTGN4+sZ+qGe3PA0qkphAtOHeFG0=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
||||
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
|
||||
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
|
||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dunglas/frankenphp"
|
||||
)
|
||||
|
||||
// PHPHandler implements http.Handler by delegating to FrankenPHP.
|
||||
// It resolves URLs to files (like Caddy's try_files) before passing
|
||||
// requests to the PHP runtime.
|
||||
type PHPHandler struct {
|
||||
docRoot string
|
||||
laravelRoot string
|
||||
}
|
||||
|
||||
// NewPHPHandler extracts the embedded Laravel app, prepares the environment,
|
||||
// initialises FrankenPHP with worker mode, and returns the handler.
|
||||
func NewPHPHandler() (*PHPHandler, *AppEnvironment, func(), error) {
|
||||
// Extract embedded Laravel to temp directory
|
||||
laravelRoot, err := extractLaravel()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("extract Laravel: %w", err)
|
||||
}
|
||||
|
||||
// Prepare persistent environment
|
||||
env, err := PrepareEnvironment(laravelRoot)
|
||||
if err != nil {
|
||||
os.RemoveAll(laravelRoot)
|
||||
return nil, nil, nil, fmt.Errorf("prepare environment: %w", err)
|
||||
}
|
||||
|
||||
docRoot := filepath.Join(laravelRoot, "public")
|
||||
|
||||
log.Printf("Laravel root: %s", laravelRoot)
|
||||
log.Printf("Document root: %s", docRoot)
|
||||
log.Printf("Data directory: %s", env.DataDir)
|
||||
log.Printf("Database: %s", env.DatabasePath)
|
||||
|
||||
// Try Octane worker mode first, fall back to standard mode.
|
||||
// Worker mode keeps Laravel booted in memory — sub-ms response times.
|
||||
workerScript := filepath.Join(laravelRoot, "vendor", "laravel", "octane", "bin", "frankenphp-worker.php")
|
||||
workerEnv := map[string]string{
|
||||
"APP_BASE_PATH": laravelRoot,
|
||||
"FRANKENPHP_WORKER": "1",
|
||||
}
|
||||
|
||||
workerMode := false
|
||||
if _, err := os.Stat(workerScript); err == nil {
|
||||
if err := frankenphp.Init(
|
||||
frankenphp.WithNumThreads(4),
|
||||
frankenphp.WithWorkers("laravel", workerScript, 2, workerEnv, nil),
|
||||
frankenphp.WithPhpIni(map[string]string{
|
||||
"display_errors": "Off",
|
||||
"opcache.enable": "1",
|
||||
}),
|
||||
); err != nil {
|
||||
log.Printf("Worker mode init failed (%v), falling back to standard mode", err)
|
||||
} else {
|
||||
workerMode = true
|
||||
}
|
||||
}
|
||||
|
||||
if !workerMode {
|
||||
if err := frankenphp.Init(
|
||||
frankenphp.WithNumThreads(4),
|
||||
frankenphp.WithPhpIni(map[string]string{
|
||||
"display_errors": "Off",
|
||||
"opcache.enable": "1",
|
||||
}),
|
||||
); err != nil {
|
||||
os.RemoveAll(laravelRoot)
|
||||
return nil, nil, nil, fmt.Errorf("init FrankenPHP: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if workerMode {
|
||||
log.Println("FrankenPHP initialised (Octane worker mode, 2 workers)")
|
||||
} else {
|
||||
log.Println("FrankenPHP initialised (standard mode, 4 threads)")
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
frankenphp.Shutdown()
|
||||
os.RemoveAll(laravelRoot)
|
||||
}
|
||||
|
||||
handler := &PHPHandler{
|
||||
docRoot: docRoot,
|
||||
laravelRoot: laravelRoot,
|
||||
}
|
||||
|
||||
return handler, env, cleanup, nil
|
||||
}
|
||||
|
||||
func (h *PHPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
urlPath := r.URL.Path
|
||||
filePath := filepath.Join(h.docRoot, filepath.Clean(urlPath))
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err == nil && info.IsDir() {
|
||||
// Directory → try index.php inside it
|
||||
urlPath = strings.TrimRight(urlPath, "/") + "/index.php"
|
||||
} else if err != nil && !strings.HasSuffix(urlPath, ".php") {
|
||||
// File not found and not a .php request → front controller
|
||||
urlPath = "/index.php"
|
||||
}
|
||||
|
||||
// Serve static assets directly (CSS, JS, images)
|
||||
if !strings.HasSuffix(urlPath, ".php") {
|
||||
staticPath := filepath.Join(h.docRoot, filepath.Clean(urlPath))
|
||||
if info, err := os.Stat(staticPath); err == nil && !info.IsDir() {
|
||||
http.ServeFile(w, r, staticPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Route to FrankenPHP
|
||||
r.URL.Path = urlPath
|
||||
|
||||
req, err := frankenphp.NewRequestWithContext(r,
|
||||
frankenphp.WithRequestDocumentRoot(h.docRoot, false),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("FrankenPHP request error: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := frankenphp.ServeHTTP(w, req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("FrankenPHP serve error: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 76 B |
|
|
@ -1,24 +0,0 @@
|
|||
// Package icons provides embedded icon assets for the Core App.
|
||||
package icons
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// TrayTemplate is the template icon for macOS systray (22x22 PNG, black on transparent).
|
||||
//
|
||||
//go:embed tray-template.png
|
||||
var TrayTemplate []byte
|
||||
|
||||
// TrayLight is the light mode icon for Windows/Linux systray.
|
||||
//
|
||||
//go:embed tray-light.png
|
||||
var TrayLight []byte
|
||||
|
||||
// TrayDark is the dark mode icon for Windows/Linux systray.
|
||||
//
|
||||
//go:embed tray-dark.png
|
||||
var TrayDark []byte
|
||||
|
||||
// AppIcon is the main application icon.
|
||||
//
|
||||
//go:embed appicon.png
|
||||
var AppIcon []byte
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 76 B |
Binary file not shown.
|
Before Width: | Height: | Size: 76 B |
Binary file not shown.
|
Before Width: | Height: | Size: 76 B |
|
|
@ -1,13 +0,0 @@
|
|||
APP_NAME="Core App"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE=/tmp/core-app/database.sqlite
|
||||
|
||||
CACHE_STORE=file
|
||||
SESSION_DRIVER=file
|
||||
LOG_CHANNEL=single
|
||||
LOG_LEVEL=warning
|
||||
5
cmd/core-app/laravel/.gitignore
vendored
5
cmd/core-app/laravel/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
|||
/vendor/
|
||||
/node_modules/
|
||||
/.env
|
||||
/bootstrap/cache/*.php
|
||||
/storage/*.key
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\AllowanceService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class QuotaMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AllowanceService $allowanceService,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$agentId = $request->header('X-Agent-ID', $request->input('agent_id', ''));
|
||||
$model = $request->input('model', '');
|
||||
|
||||
if ($agentId === '') {
|
||||
return response()->json([
|
||||
'error' => 'agent_id is required',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = $this->allowanceService->check($agentId, $model);
|
||||
|
||||
if (! $result['allowed']) {
|
||||
return response()->json([
|
||||
'error' => 'quota_exceeded',
|
||||
'status' => $result['status'],
|
||||
'reason' => $result['reason'],
|
||||
'remaining_tokens' => $result['remaining_tokens'],
|
||||
'remaining_jobs' => $result['remaining_jobs'],
|
||||
], 429);
|
||||
}
|
||||
|
||||
// Attach quota info to request for downstream use
|
||||
$request->merge(['_quota' => $result]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Counter extends Component
|
||||
{
|
||||
public int $count = 0;
|
||||
|
||||
public function increment(): void
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
|
||||
public function decrement(): void
|
||||
{
|
||||
$this->count--;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.counter');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ActivityFeed extends Component
|
||||
{
|
||||
public array $entries = [];
|
||||
public string $agentFilter = 'all';
|
||||
public string $typeFilter = 'all';
|
||||
public bool $showOnlyQuestions = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadEntries();
|
||||
}
|
||||
|
||||
public function loadEntries(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real-time WebSocket feed
|
||||
$this->entries = [
|
||||
[
|
||||
'id' => 'act-001',
|
||||
'agent' => 'Athena',
|
||||
'type' => 'code_write',
|
||||
'message' => 'Created AgentFleet Livewire component',
|
||||
'job' => '#96',
|
||||
'timestamp' => now()->subMinutes(2)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-002',
|
||||
'agent' => 'Athena',
|
||||
'type' => 'tool_call',
|
||||
'message' => 'Read file: cmd/core-app/laravel/composer.json',
|
||||
'job' => '#96',
|
||||
'timestamp' => now()->subMinutes(5)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-003',
|
||||
'agent' => 'Clotho',
|
||||
'type' => 'question',
|
||||
'message' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
|
||||
'job' => '#84',
|
||||
'timestamp' => now()->subMinutes(8)->toIso8601String(),
|
||||
'is_question' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'act-004',
|
||||
'agent' => 'Virgil',
|
||||
'type' => 'pr_created',
|
||||
'message' => 'Opened PR #89: fix WebSocket reconnection logic',
|
||||
'job' => '#89',
|
||||
'timestamp' => now()->subMinutes(15)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-005',
|
||||
'agent' => 'Virgil',
|
||||
'type' => 'test_run',
|
||||
'message' => 'All 47 tests passed (0.8s)',
|
||||
'job' => '#89',
|
||||
'timestamp' => now()->subMinutes(18)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-006',
|
||||
'agent' => 'Athena',
|
||||
'type' => 'git_push',
|
||||
'message' => 'Pushed branch feat/agentic-dashboard',
|
||||
'job' => '#96',
|
||||
'timestamp' => now()->subMinutes(22)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-007',
|
||||
'agent' => 'Clotho',
|
||||
'type' => 'code_write',
|
||||
'message' => 'Added input validation for MCP file_write paths',
|
||||
'job' => '#84',
|
||||
'timestamp' => now()->subMinutes(30)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getFilteredEntriesProperty(): array
|
||||
{
|
||||
return array_filter($this->entries, function ($entry) {
|
||||
if ($this->showOnlyQuestions && !$entry['is_question']) {
|
||||
return false;
|
||||
}
|
||||
if ($this->agentFilter !== 'all' && $entry['agent'] !== $this->agentFilter) {
|
||||
return false;
|
||||
}
|
||||
if ($this->typeFilter !== 'all' && $entry['type'] !== $this->typeFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.activity-feed');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class AgentFleet extends Component
|
||||
{
|
||||
/** @var array<int, array{name: string, host: string, model: string, status: string, job: string, heartbeat: string, uptime: string}> */
|
||||
public array $agents = [];
|
||||
|
||||
public ?string $selectedAgent = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadAgents();
|
||||
}
|
||||
|
||||
public function loadAgents(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real API calls to Go backend
|
||||
$this->agents = [
|
||||
[
|
||||
'id' => 'athena',
|
||||
'name' => 'Athena',
|
||||
'host' => 'studio.snider.dev',
|
||||
'model' => 'claude-opus-4-6',
|
||||
'status' => 'working',
|
||||
'job' => '#96 agentic dashboard',
|
||||
'heartbeat' => 'green',
|
||||
'uptime' => '4h 23m',
|
||||
'tokens_today' => 142_580,
|
||||
'jobs_completed' => 3,
|
||||
],
|
||||
[
|
||||
'id' => 'virgil',
|
||||
'name' => 'Virgil',
|
||||
'host' => 'studio.snider.dev',
|
||||
'model' => 'claude-opus-4-6',
|
||||
'status' => 'idle',
|
||||
'job' => '',
|
||||
'heartbeat' => 'green',
|
||||
'uptime' => '12h 07m',
|
||||
'tokens_today' => 89_230,
|
||||
'jobs_completed' => 5,
|
||||
],
|
||||
[
|
||||
'id' => 'clotho',
|
||||
'name' => 'Clotho',
|
||||
'host' => 'darwin-au',
|
||||
'model' => 'claude-sonnet-4-5',
|
||||
'status' => 'working',
|
||||
'job' => '#84 security audit',
|
||||
'heartbeat' => 'yellow',
|
||||
'uptime' => '1h 45m',
|
||||
'tokens_today' => 34_100,
|
||||
'jobs_completed' => 1,
|
||||
],
|
||||
[
|
||||
'id' => 'charon',
|
||||
'name' => 'Charon',
|
||||
'host' => 'linux.snider.dev',
|
||||
'model' => 'claude-haiku-4-5',
|
||||
'status' => 'unhealthy',
|
||||
'job' => '',
|
||||
'heartbeat' => 'red',
|
||||
'uptime' => '0m',
|
||||
'tokens_today' => 0,
|
||||
'jobs_completed' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function selectAgent(string $agentId): void
|
||||
{
|
||||
$this->selectedAgent = $this->selectedAgent === $agentId ? null : $agentId;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.agent-fleet');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class HumanActions extends Component
|
||||
{
|
||||
public array $pendingQuestions = [];
|
||||
public array $reviewGates = [];
|
||||
public string $answerText = '';
|
||||
public ?string $answeringId = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadPending();
|
||||
}
|
||||
|
||||
public function loadPending(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real data from Go backend
|
||||
$this->pendingQuestions = [
|
||||
[
|
||||
'id' => 'q-001',
|
||||
'agent' => 'Clotho',
|
||||
'job' => '#84',
|
||||
'question' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
|
||||
'asked_at' => now()->subMinutes(8)->toIso8601String(),
|
||||
'context' => 'Working on security audit — found unvalidated input in transport layer.',
|
||||
],
|
||||
];
|
||||
|
||||
$this->reviewGates = [
|
||||
[
|
||||
'id' => 'rg-001',
|
||||
'agent' => 'Virgil',
|
||||
'job' => '#89',
|
||||
'type' => 'pr_review',
|
||||
'title' => 'PR #89: fix WebSocket reconnection logic',
|
||||
'description' => 'Adds exponential backoff and connection state tracking.',
|
||||
'submitted_at' => now()->subMinutes(15)->toIso8601String(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function startAnswer(string $questionId): void
|
||||
{
|
||||
$this->answeringId = $questionId;
|
||||
$this->answerText = '';
|
||||
}
|
||||
|
||||
public function submitAnswer(): void
|
||||
{
|
||||
if (! $this->answeringId || trim($this->answerText) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove answered question from list
|
||||
$this->pendingQuestions = array_values(
|
||||
array_filter($this->pendingQuestions, fn ($q) => $q['id'] !== $this->answeringId)
|
||||
);
|
||||
|
||||
$this->answeringId = null;
|
||||
$this->answerText = '';
|
||||
}
|
||||
|
||||
public function cancelAnswer(): void
|
||||
{
|
||||
$this->answeringId = null;
|
||||
$this->answerText = '';
|
||||
}
|
||||
|
||||
public function approveGate(string $gateId): void
|
||||
{
|
||||
$this->reviewGates = array_values(
|
||||
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
|
||||
);
|
||||
}
|
||||
|
||||
public function rejectGate(string $gateId): void
|
||||
{
|
||||
$this->reviewGates = array_values(
|
||||
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.human-actions');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class JobQueue extends Component
|
||||
{
|
||||
public array $jobs = [];
|
||||
public string $statusFilter = 'all';
|
||||
public string $agentFilter = 'all';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadJobs();
|
||||
}
|
||||
|
||||
public function loadJobs(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real API calls to Go backend
|
||||
$this->jobs = [
|
||||
[
|
||||
'id' => 'job-001',
|
||||
'issue' => '#96',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'feat(agentic): real-time dashboard',
|
||||
'agent' => 'Athena',
|
||||
'status' => 'in_progress',
|
||||
'priority' => 1,
|
||||
'queued_at' => now()->subMinutes(45)->toIso8601String(),
|
||||
'started_at' => now()->subMinutes(30)->toIso8601String(),
|
||||
],
|
||||
[
|
||||
'id' => 'job-002',
|
||||
'issue' => '#84',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'fix: security audit findings',
|
||||
'agent' => 'Clotho',
|
||||
'status' => 'in_progress',
|
||||
'priority' => 2,
|
||||
'queued_at' => now()->subHours(2)->toIso8601String(),
|
||||
'started_at' => now()->subHours(1)->toIso8601String(),
|
||||
],
|
||||
[
|
||||
'id' => 'job-003',
|
||||
'issue' => '#102',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'feat: add rate limiting to MCP',
|
||||
'agent' => null,
|
||||
'status' => 'queued',
|
||||
'priority' => 3,
|
||||
'queued_at' => now()->subMinutes(10)->toIso8601String(),
|
||||
'started_at' => null,
|
||||
],
|
||||
[
|
||||
'id' => 'job-004',
|
||||
'issue' => '#89',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'fix: WebSocket reconnection',
|
||||
'agent' => 'Virgil',
|
||||
'status' => 'review',
|
||||
'priority' => 2,
|
||||
'queued_at' => now()->subHours(4)->toIso8601String(),
|
||||
'started_at' => now()->subHours(3)->toIso8601String(),
|
||||
],
|
||||
[
|
||||
'id' => 'job-005',
|
||||
'issue' => '#78',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'docs: update CLAUDE.md',
|
||||
'agent' => 'Virgil',
|
||||
'status' => 'completed',
|
||||
'priority' => 4,
|
||||
'queued_at' => now()->subHours(6)->toIso8601String(),
|
||||
'started_at' => now()->subHours(5)->toIso8601String(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function updatedStatusFilter(): void
|
||||
{
|
||||
// Livewire auto-updates the view
|
||||
}
|
||||
|
||||
public function cancelJob(string $jobId): void
|
||||
{
|
||||
$this->jobs = array_map(function ($job) use ($jobId) {
|
||||
if ($job['id'] === $jobId && in_array($job['status'], ['queued', 'in_progress'])) {
|
||||
$job['status'] = 'cancelled';
|
||||
}
|
||||
return $job;
|
||||
}, $this->jobs);
|
||||
}
|
||||
|
||||
public function retryJob(string $jobId): void
|
||||
{
|
||||
$this->jobs = array_map(function ($job) use ($jobId) {
|
||||
if ($job['id'] === $jobId && in_array($job['status'], ['failed', 'cancelled'])) {
|
||||
$job['status'] = 'queued';
|
||||
$job['agent'] = null;
|
||||
}
|
||||
return $job;
|
||||
}, $this->jobs);
|
||||
}
|
||||
|
||||
public function getFilteredJobsProperty(): array
|
||||
{
|
||||
return array_filter($this->jobs, function ($job) {
|
||||
if ($this->statusFilter !== 'all' && $job['status'] !== $this->statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if ($this->agentFilter !== 'all' && ($job['agent'] ?? '') !== $this->agentFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.job-queue');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Metrics extends Component
|
||||
{
|
||||
public array $stats = [];
|
||||
public array $throughputData = [];
|
||||
public array $costBreakdown = [];
|
||||
public float $budgetUsed = 0;
|
||||
public float $budgetLimit = 0;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadMetrics();
|
||||
}
|
||||
|
||||
public function loadMetrics(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real metrics from Go backend
|
||||
$this->stats = [
|
||||
'jobs_completed' => 12,
|
||||
'prs_merged' => 8,
|
||||
'tokens_used' => 1_245_800,
|
||||
'cost_today' => 18.42,
|
||||
'active_agents' => 3,
|
||||
'queue_depth' => 4,
|
||||
];
|
||||
|
||||
$this->budgetUsed = 18.42;
|
||||
$this->budgetLimit = 50.00;
|
||||
|
||||
// Hourly throughput for chart
|
||||
$this->throughputData = [
|
||||
['hour' => '00:00', 'jobs' => 0, 'tokens' => 0],
|
||||
['hour' => '02:00', 'jobs' => 0, 'tokens' => 0],
|
||||
['hour' => '04:00', 'jobs' => 1, 'tokens' => 45_000],
|
||||
['hour' => '06:00', 'jobs' => 2, 'tokens' => 120_000],
|
||||
['hour' => '08:00', 'jobs' => 3, 'tokens' => 195_000],
|
||||
['hour' => '10:00', 'jobs' => 2, 'tokens' => 280_000],
|
||||
['hour' => '12:00', 'jobs' => 1, 'tokens' => 340_000],
|
||||
['hour' => '14:00', 'jobs' => 3, 'tokens' => 450_000],
|
||||
];
|
||||
|
||||
$this->costBreakdown = [
|
||||
['model' => 'claude-opus-4-6', 'cost' => 12.80, 'tokens' => 856_000],
|
||||
['model' => 'claude-sonnet-4-5', 'cost' => 4.20, 'tokens' => 312_000],
|
||||
['model' => 'claude-haiku-4-5', 'cost' => 1.42, 'tokens' => 77_800],
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.metrics');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AgentAllowance extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'agent_id',
|
||||
'daily_token_limit',
|
||||
'daily_job_limit',
|
||||
'concurrent_jobs',
|
||||
'max_job_duration_minutes',
|
||||
'model_allowlist',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'daily_token_limit' => 'integer',
|
||||
'daily_job_limit' => 'integer',
|
||||
'concurrent_jobs' => 'integer',
|
||||
'max_job_duration_minutes' => 'integer',
|
||||
'model_allowlist' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function usageRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuotaUsage::class, 'agent_id', 'agent_id');
|
||||
}
|
||||
|
||||
public function todayUsage(): ?QuotaUsage
|
||||
{
|
||||
return $this->usageRecords()
|
||||
->where('period_date', now()->toDateString())
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ModelQuota extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'model',
|
||||
'daily_token_budget',
|
||||
'hourly_rate_limit',
|
||||
'cost_ceiling',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'daily_token_budget' => 'integer',
|
||||
'hourly_rate_limit' => 'integer',
|
||||
'cost_ceiling' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QuotaUsage extends Model
|
||||
{
|
||||
protected $table = 'quota_usage';
|
||||
|
||||
protected $fillable = [
|
||||
'agent_id',
|
||||
'tokens_used',
|
||||
'jobs_started',
|
||||
'active_jobs',
|
||||
'period_date',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tokens_used' => 'integer',
|
||||
'jobs_started' => 'integer',
|
||||
'active_jobs' => 'integer',
|
||||
'period_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function allowance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AgentAllowance::class, 'agent_id', 'agent_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UsageReport extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'agent_id',
|
||||
'job_id',
|
||||
'model',
|
||||
'tokens_in',
|
||||
'tokens_out',
|
||||
'event',
|
||||
'reported_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tokens_in' => 'integer',
|
||||
'tokens_out' => 'integer',
|
||||
'reported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Forgejo\ForgejoService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Throwable;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(ForgejoService::class, function ($app): ForgejoService {
|
||||
/** @var array<string, mixed> $config */
|
||||
$config = $app['config']->get('forgejo', []);
|
||||
|
||||
return new ForgejoService(
|
||||
instances: $config['instances'] ?? [],
|
||||
defaultInstance: $config['default'] ?? 'forge',
|
||||
timeout: $config['timeout'] ?? 30,
|
||||
retryTimes: $config['retry_times'] ?? 3,
|
||||
retrySleep: $config['retry_sleep'] ?? 500,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Auto-migrate on first boot. Single-user desktop app with
|
||||
// SQLite — safe to run on every startup. The --force flag
|
||||
// is required in production, --no-interaction prevents prompts.
|
||||
try {
|
||||
Artisan::call('migrate', [
|
||||
'--force' => true,
|
||||
'--no-interaction' => true,
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
// Silently skip — DB might not exist yet (e.g. during
|
||||
// composer operations or first extraction).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AgentAllowance;
|
||||
use App\Models\ModelQuota;
|
||||
use App\Models\QuotaUsage;
|
||||
use App\Models\UsageReport;
|
||||
|
||||
class AllowanceService
|
||||
{
|
||||
/**
|
||||
* Pre-dispatch check: verify agent has remaining allowance.
|
||||
*
|
||||
* @return array{allowed: bool, status: string, remaining_tokens: int, remaining_jobs: int, reason: ?string}
|
||||
*/
|
||||
public function check(string $agentId, string $model = ''): array
|
||||
{
|
||||
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
|
||||
|
||||
if (! $allowance) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'remaining_tokens' => 0,
|
||||
'remaining_jobs' => 0,
|
||||
'reason' => 'no allowance configured for agent',
|
||||
];
|
||||
}
|
||||
|
||||
$usage = QuotaUsage::firstOrCreate(
|
||||
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
|
||||
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
|
||||
);
|
||||
|
||||
$result = [
|
||||
'allowed' => true,
|
||||
'status' => 'ok',
|
||||
'remaining_tokens' => -1,
|
||||
'remaining_jobs' => -1,
|
||||
'reason' => null,
|
||||
];
|
||||
|
||||
// Check model allowlist
|
||||
if ($model !== '' && ! empty($allowance->model_allowlist)) {
|
||||
if (! in_array($model, $allowance->model_allowlist, true)) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => "model not in allowlist: {$model}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily token limit
|
||||
if ($allowance->daily_token_limit > 0) {
|
||||
$remaining = $allowance->daily_token_limit - $usage->tokens_used;
|
||||
$result['remaining_tokens'] = $remaining;
|
||||
|
||||
if ($remaining <= 0) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => 'daily token limit exceeded',
|
||||
]);
|
||||
}
|
||||
|
||||
$ratio = $usage->tokens_used / $allowance->daily_token_limit;
|
||||
if ($ratio >= 0.8) {
|
||||
$result['status'] = 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily job limit
|
||||
if ($allowance->daily_job_limit > 0) {
|
||||
$remaining = $allowance->daily_job_limit - $usage->jobs_started;
|
||||
$result['remaining_jobs'] = $remaining;
|
||||
|
||||
if ($remaining <= 0) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => 'daily job limit exceeded',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check concurrent jobs
|
||||
if ($allowance->concurrent_jobs > 0 && $usage->active_jobs >= $allowance->concurrent_jobs) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => 'concurrent job limit reached',
|
||||
]);
|
||||
}
|
||||
|
||||
// Check global model quota
|
||||
if ($model !== '') {
|
||||
$modelQuota = ModelQuota::where('model', $model)->first();
|
||||
|
||||
if ($modelQuota && $modelQuota->daily_token_budget > 0) {
|
||||
$modelUsage = UsageReport::where('model', $model)
|
||||
->whereDate('reported_at', now()->toDateString())
|
||||
->sum(\DB::raw('tokens_in + tokens_out'));
|
||||
|
||||
if ($modelUsage >= $modelQuota->daily_token_budget) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => "global model token budget exceeded for: {$model}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage from an agent runner report.
|
||||
*/
|
||||
public function recordUsage(array $report): void
|
||||
{
|
||||
$agentId = $report['agent_id'];
|
||||
$totalTokens = ($report['tokens_in'] ?? 0) + ($report['tokens_out'] ?? 0);
|
||||
|
||||
$usage = QuotaUsage::firstOrCreate(
|
||||
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
|
||||
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
|
||||
);
|
||||
|
||||
// Persist the raw report
|
||||
UsageReport::create([
|
||||
'agent_id' => $report['agent_id'],
|
||||
'job_id' => $report['job_id'],
|
||||
'model' => $report['model'] ?? null,
|
||||
'tokens_in' => $report['tokens_in'] ?? 0,
|
||||
'tokens_out' => $report['tokens_out'] ?? 0,
|
||||
'event' => $report['event'],
|
||||
'reported_at' => $report['timestamp'] ?? now(),
|
||||
]);
|
||||
|
||||
match ($report['event']) {
|
||||
'job_started' => $usage->increment('jobs_started') || $usage->increment('active_jobs'),
|
||||
'job_completed' => $this->handleCompleted($usage, $totalTokens),
|
||||
'job_failed' => $this->handleFailed($usage, $totalTokens),
|
||||
'job_cancelled' => $this->handleCancelled($usage, $totalTokens),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset daily usage counters for an agent.
|
||||
*/
|
||||
public function resetAgent(string $agentId): void
|
||||
{
|
||||
QuotaUsage::updateOrCreate(
|
||||
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
|
||||
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
|
||||
);
|
||||
}
|
||||
|
||||
private function handleCompleted(QuotaUsage $usage, int $totalTokens): void
|
||||
{
|
||||
$usage->increment('tokens_used', $totalTokens);
|
||||
$usage->decrement('active_jobs');
|
||||
}
|
||||
|
||||
private function handleFailed(QuotaUsage $usage, int $totalTokens): void
|
||||
{
|
||||
$returnAmount = intdiv($totalTokens, 2);
|
||||
$usage->increment('tokens_used', $totalTokens - $returnAmount);
|
||||
$usage->decrement('active_jobs');
|
||||
}
|
||||
|
||||
private function handleCancelled(QuotaUsage $usage, int $totalTokens): void
|
||||
{
|
||||
$usage->decrement('active_jobs');
|
||||
// 100% returned — no token charge
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Forgejo;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Low-level HTTP client for a single Forgejo instance.
|
||||
*
|
||||
* Wraps the Laravel HTTP client with token auth, retry, and
|
||||
* base-URL scoping so callers never deal with raw HTTP details.
|
||||
*/
|
||||
class ForgejoClient
|
||||
{
|
||||
private PendingRequest $http;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $baseUrl,
|
||||
private readonly string $token,
|
||||
int $timeout = 30,
|
||||
int $retryTimes = 3,
|
||||
int $retrySleep = 500,
|
||||
) {
|
||||
if ($this->token === '') {
|
||||
throw new RuntimeException("Forgejo API token is required for {$this->baseUrl}");
|
||||
}
|
||||
|
||||
$this->http = Http::baseUrl(rtrim($this->baseUrl, '/') . '/api/v1')
|
||||
->withHeaders([
|
||||
'Authorization' => "token {$this->token}",
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
->timeout($timeout)
|
||||
->retry($retryTimes, $retrySleep, fn (?\Throwable $e, PendingRequest $req): bool =>
|
||||
$e instanceof \Illuminate\Http\Client\ConnectionException
|
||||
);
|
||||
}
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
// ----- Generic verbs -----
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function get(string $path, array $query = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->get($path, $query));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function post(string $path, array $data = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->post($path, $data));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function patch(string $path, array $data = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->patch($path, $data));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function put(string $path, array $data = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->put($path, $data));
|
||||
}
|
||||
|
||||
public function delete(string $path): void
|
||||
{
|
||||
$response = $this->http->delete($path);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo DELETE {$path} failed [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a path and return the raw response body as a string.
|
||||
* Useful for endpoints that return non-JSON content (e.g. diffs).
|
||||
*/
|
||||
public function getRaw(string $path, array $query = []): string
|
||||
{
|
||||
$response = $this->http->get($path, $query);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo GET {$path} failed [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate through all pages of a list endpoint.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function paginate(string $path, array $query = [], int $limit = 50): array
|
||||
{
|
||||
$all = [];
|
||||
$page = 1;
|
||||
|
||||
do {
|
||||
$response = $this->http->get($path, array_merge($query, [
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
]));
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo GET {$path} page {$page} failed [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
|
||||
$items = $response->json();
|
||||
|
||||
if (!is_array($items) || $items === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
array_push($all, ...$items);
|
||||
|
||||
// Forgejo returns total count in x-total-count header.
|
||||
$total = (int) $response->header('x-total-count');
|
||||
$page++;
|
||||
} while (count($all) < $total);
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
// ----- Internals -----
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function decodeOrFail(Response $response): array
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo API error [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Forgejo;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Business-logic layer for Forgejo operations.
|
||||
*
|
||||
* Manages multiple Forgejo instances (forge, dev, qa) and provides
|
||||
* a unified API for issues, pull requests, repositories, and user
|
||||
* management. Mirrors the Go pkg/forge API surface.
|
||||
*/
|
||||
class ForgejoService
|
||||
{
|
||||
/** @var array<string, ForgejoClient> */
|
||||
private array $clients = [];
|
||||
|
||||
private string $defaultInstance;
|
||||
|
||||
/**
|
||||
* @param array<string, array{url: string, token: string}> $instances
|
||||
*/
|
||||
public function __construct(
|
||||
array $instances,
|
||||
string $defaultInstance = 'forge',
|
||||
private readonly int $timeout = 30,
|
||||
private readonly int $retryTimes = 3,
|
||||
private readonly int $retrySleep = 500,
|
||||
) {
|
||||
$this->defaultInstance = $defaultInstance;
|
||||
|
||||
foreach ($instances as $name => $cfg) {
|
||||
if (($cfg['token'] ?? '') === '') {
|
||||
continue; // skip unconfigured instances
|
||||
}
|
||||
|
||||
$this->clients[$name] = new ForgejoClient(
|
||||
baseUrl: $cfg['url'],
|
||||
token: $cfg['token'],
|
||||
timeout: $this->timeout,
|
||||
retryTimes: $this->retryTimes,
|
||||
retrySleep: $this->retrySleep,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Instance resolution
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
public function client(?string $instance = null): ForgejoClient
|
||||
{
|
||||
$name = $instance ?? $this->defaultInstance;
|
||||
|
||||
return $this->clients[$name]
|
||||
?? throw new RuntimeException("Forgejo instance '{$name}' is not configured or has no token");
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function instances(): array
|
||||
{
|
||||
return array_keys($this->clients);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Issue Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createIssue(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $body = '',
|
||||
array $labels = [],
|
||||
string $assignee = '',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
$data = ['title' => $title, 'body' => $body];
|
||||
|
||||
if ($labels !== []) {
|
||||
$data['labels'] = $labels;
|
||||
}
|
||||
if ($assignee !== '') {
|
||||
$data['assignees'] = [$assignee];
|
||||
}
|
||||
|
||||
return $this->client($instance)->post("/repos/{$owner}/{$repo}/issues", $data);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function updateIssue(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $number,
|
||||
array $fields,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->patch("/repos/{$owner}/{$repo}/issues/{$number}", $fields);
|
||||
}
|
||||
|
||||
public function closeIssue(string $owner, string $repo, int $number, ?string $instance = null): array
|
||||
{
|
||||
return $this->updateIssue($owner, $repo, $number, ['state' => 'closed'], $instance);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function addComment(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $number,
|
||||
string $body,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->post(
|
||||
"/repos/{$owner}/{$repo}/issues/{$number}/comments",
|
||||
['body' => $body],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listIssues(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $state = 'open',
|
||||
int $page = 1,
|
||||
int $limit = 50,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->get("/repos/{$owner}/{$repo}/issues", [
|
||||
'state' => $state,
|
||||
'type' => 'issues',
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Pull Request Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createPR(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $head,
|
||||
string $base,
|
||||
string $title,
|
||||
string $body = '',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->post("/repos/{$owner}/{$repo}/pulls", [
|
||||
'head' => $head,
|
||||
'base' => $base,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
public function mergePR(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $number,
|
||||
string $strategy = 'merge',
|
||||
?string $instance = null,
|
||||
): void {
|
||||
$this->client($instance)->post("/repos/{$owner}/{$repo}/pulls/{$number}/merge", [
|
||||
'Do' => $strategy,
|
||||
'delete_branch_after_merge' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listPRs(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $state = 'open',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->paginate("/repos/{$owner}/{$repo}/pulls", [
|
||||
'state' => $state,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getPRDiff(string $owner, string $repo, int $number, ?string $instance = null): string
|
||||
{
|
||||
return $this->client($instance)->getRaw("/repos/{$owner}/{$repo}/pulls/{$number}.diff");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Repository Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listRepos(string $org, ?string $instance = null): array
|
||||
{
|
||||
return $this->client($instance)->paginate("/orgs/{$org}/repos");
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getRepo(string $owner, string $name, ?string $instance = null): array
|
||||
{
|
||||
return $this->client($instance)->get("/repos/{$owner}/{$name}");
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createBranch(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $name,
|
||||
string $from = '',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
$data = ['new_branch_name' => $name];
|
||||
|
||||
if ($from !== '') {
|
||||
$data['old_branch_name'] = $from;
|
||||
}
|
||||
|
||||
return $this->client($instance)->post("/repos/{$owner}/{$repo}/branches", $data);
|
||||
}
|
||||
|
||||
public function deleteBranch(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $name,
|
||||
?string $instance = null,
|
||||
): void {
|
||||
$this->client($instance)->delete("/repos/{$owner}/{$repo}/branches/{$name}");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// User / Token Management
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createUser(
|
||||
string $username,
|
||||
string $email,
|
||||
string $password,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->post('/admin/users', [
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'must_change_password' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createToken(
|
||||
string $username,
|
||||
string $name,
|
||||
array $scopes = [],
|
||||
?string $instance = null,
|
||||
): array {
|
||||
$data = ['name' => $name];
|
||||
|
||||
if ($scopes !== []) {
|
||||
$data['scopes'] = $scopes;
|
||||
}
|
||||
|
||||
return $this->client($instance)->post("/users/{$username}/tokens", $data);
|
||||
}
|
||||
|
||||
public function revokeToken(string $username, int $tokenId, ?string $instance = null): void
|
||||
{
|
||||
$this->client($instance)->delete("/users/{$username}/tokens/{$tokenId}");
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function addToOrg(
|
||||
string $username,
|
||||
string $org,
|
||||
int $teamId,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->put("/teams/{$teamId}/members/{$username}");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Org Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listOrgs(?string $instance = null): array
|
||||
{
|
||||
return $this->client($instance)->paginate('/user/orgs');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
|
||||
$status = $kernel->handle(
|
||||
$input = new Symfony\Component\Console\Input\ArgvInput,
|
||||
new Symfony\Component\Console\Output\ConsoleOutput
|
||||
);
|
||||
|
||||
$kernel->terminate($input, $status);
|
||||
|
||||
exit($status);
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
})
|
||||
->create();
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"name": "host-uk/core-app",
|
||||
"description": "Embedded Laravel application for Core App desktop",
|
||||
"license": "EUPL-1.2",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/octane": "^2.0",
|
||||
"livewire/livewire": "^4.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"@php artisan package:discover --ansi"
|
||||
]
|
||||
}
|
||||
}
|
||||
6149
cmd/core-app/laravel/composer.lock
generated
6149
cmd/core-app/laravel/composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'name' => env('APP_NAME', 'Core App'),
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
'timezone' => 'UTC',
|
||||
'locale' => 'en',
|
||||
'fallback_locale' => 'en',
|
||||
'faker_locale' => 'en_GB',
|
||||
'cipher' => 'AES-256-CBC',
|
||||
'key' => env('APP_KEY'),
|
||||
'maintenance' => [
|
||||
'driver' => 'file',
|
||||
],
|
||||
];
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default' => env('CACHE_STORE', 'file'),
|
||||
|
||||
'stores' => [
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
],
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', 'core_app_cache_'),
|
||||
];
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default' => 'sqlite',
|
||||
|
||||
'connections' => [
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => true,
|
||||
'busy_timeout' => 5000,
|
||||
'journal_mode' => 'wal',
|
||||
'synchronous' => 'normal',
|
||||
],
|
||||
],
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
];
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Forgejo Instance
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The instance name to use when no explicit instance is specified.
|
||||
|
|
||||
*/
|
||||
'default' => env('FORGEJO_DEFAULT', 'forge'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Forgejo Instances
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Each entry defines a Forgejo instance the platform can talk to.
|
||||
| The service auto-routes by matching the configured URL.
|
||||
|
|
||||
| url — Base URL of the Forgejo instance (no trailing slash)
|
||||
| token — Admin API token for the instance
|
||||
|
|
||||
*/
|
||||
'instances' => [
|
||||
'forge' => [
|
||||
'url' => env('FORGEJO_FORGE_URL', 'https://forge.lthn.ai'),
|
||||
'token' => env('FORGEJO_FORGE_TOKEN', ''),
|
||||
],
|
||||
'dev' => [
|
||||
'url' => env('FORGEJO_DEV_URL', 'https://dev.lthn.ai'),
|
||||
'token' => env('FORGEJO_DEV_TOKEN', ''),
|
||||
],
|
||||
'qa' => [
|
||||
'url' => env('FORGEJO_QA_URL', 'https://qa.lthn.ai'),
|
||||
'token' => env('FORGEJO_QA_TOKEN', ''),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Client Settings
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'timeout' => (int) env('FORGEJO_TIMEOUT', 30),
|
||||
'retry_times' => (int) env('FORGEJO_RETRY_TIMES', 3),
|
||||
'retry_sleep' => (int) env('FORGEJO_RETRY_SLEEP', 500),
|
||||
];
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default' => env('LOG_CHANNEL', 'single'),
|
||||
|
||||
'channels' => [
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'warning'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => Monolog\Handler\StreamHandler::class,
|
||||
'with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'processors' => [Monolog\Processor\PsrLogMessageProcessor::class],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'driver' => env('SESSION_DRIVER', 'file'),
|
||||
'lifetime' => env('SESSION_LIFETIME', 120),
|
||||
'expire_on_close' => true,
|
||||
'encrypt' => false,
|
||||
'files' => storage_path('framework/sessions'),
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
'table' => 'sessions',
|
||||
'store' => env('SESSION_STORE'),
|
||||
'lottery' => [2, 100],
|
||||
'cookie' => env('SESSION_COOKIE', 'core_app_session'),
|
||||
'path' => '/',
|
||||
'domain' => null,
|
||||
'secure' => false,
|
||||
'http_only' => true,
|
||||
'same_site' => 'lax',
|
||||
'partitioned' => false,
|
||||
];
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'paths' => [
|
||||
resource_path('views'),
|
||||
],
|
||||
'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))),
|
||||
];
|
||||
Binary file not shown.
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache_locks');
|
||||
Schema::dropIfExists('cache');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('agent_allowances', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('agent_id')->unique();
|
||||
$table->bigInteger('daily_token_limit')->default(0);
|
||||
$table->integer('daily_job_limit')->default(0);
|
||||
$table->integer('concurrent_jobs')->default(1);
|
||||
$table->integer('max_job_duration_minutes')->default(0);
|
||||
$table->json('model_allowlist')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('quota_usage', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('agent_id')->index();
|
||||
$table->bigInteger('tokens_used')->default(0);
|
||||
$table->integer('jobs_started')->default(0);
|
||||
$table->integer('active_jobs')->default(0);
|
||||
$table->date('period_date')->index();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['agent_id', 'period_date']);
|
||||
});
|
||||
|
||||
Schema::create('model_quotas', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('model')->unique();
|
||||
$table->bigInteger('daily_token_budget')->default(0);
|
||||
$table->integer('hourly_rate_limit')->default(0);
|
||||
$table->bigInteger('cost_ceiling')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('usage_reports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('agent_id')->index();
|
||||
$table->string('job_id')->index();
|
||||
$table->string('model')->nullable();
|
||||
$table->bigInteger('tokens_in')->default(0);
|
||||
$table->bigInteger('tokens_out')->default(0);
|
||||
$table->string('event');
|
||||
$table->timestamp('reported_at');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('repo_limits', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('repo')->unique();
|
||||
$table->integer('max_daily_prs')->default(0);
|
||||
$table->integer('max_daily_issues')->default(0);
|
||||
$table->integer('cooldown_after_failure_minutes')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('repo_limits');
|
||||
Schema::dropIfExists('usage_reports');
|
||||
Schema::dropIfExists('model_quotas');
|
||||
Schema::dropIfExists('quota_usage');
|
||||
Schema::dropIfExists('agent_allowances');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
(require_once __DIR__.'/../bootstrap/app.php')
|
||||
->handleRequest(Request::capture());
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $title ?? 'Agentic Dashboard' }} — Core</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: { DEFAULT: '#0d1117', raised: '#161b22', overlay: '#21262d' },
|
||||
border: { DEFAULT: '#30363d', subtle: '#21262d' },
|
||||
accent: { DEFAULT: '#39d0d8', dim: '#1b6b6f' },
|
||||
success: '#238636',
|
||||
warning: '#d29922',
|
||||
danger: '#da3633',
|
||||
muted: '#8b949e',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
||||
.heartbeat { animation: pulse-dot 2s ease-in-out infinite; }
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
|
||||
</style>
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="h-full bg-surface text-gray-200 antialiased">
|
||||
<div class="flex h-full" x-data="{ sidebarOpen: true }">
|
||||
{{-- Sidebar --}}
|
||||
<aside class="flex flex-col w-56 border-r border-border bg-surface-raised shrink-0 transition-all"
|
||||
:class="sidebarOpen ? 'w-56' : 'w-16'">
|
||||
<div class="flex items-center gap-2 px-4 h-14 border-b border-border">
|
||||
<svg class="w-6 h-6 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-sm tracking-wide" x-show="sidebarOpen" x-cloak>Agentic</span>
|
||||
</div>
|
||||
<nav class="flex-1 py-2 space-y-0.5 px-2">
|
||||
<a href="{{ route('dashboard') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
|
||||
<span x-show="sidebarOpen">Dashboard</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.agents') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.agents') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<span x-show="sidebarOpen">Agent Fleet</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.jobs') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.jobs') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
||||
<span x-show="sidebarOpen">Job Queue</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.activity') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.activity') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||
<span x-show="sidebarOpen">Activity</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="border-t border-border p-2">
|
||||
<button @click="sidebarOpen = !sidebarOpen"
|
||||
class="flex items-center justify-center w-full px-3 py-2 text-muted hover:text-white rounded-md hover:bg-surface-overlay transition">
|
||||
<svg class="w-4 h-4 transition-transform" :class="sidebarOpen ? '' : 'rotate-180'" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{{-- Main content --}}
|
||||
<main class="flex-1 overflow-auto">
|
||||
<header class="sticky top-0 z-10 flex items-center justify-between h-14 px-6 border-b border-border bg-surface/80 backdrop-blur">
|
||||
<h1 class="text-sm font-semibold">{{ $title ?? 'Dashboard' }}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-xs text-muted"
|
||||
x-data="{ connected: true }"
|
||||
x-init="
|
||||
setInterval(() => {
|
||||
connected = navigator.onLine;
|
||||
}, 3000)
|
||||
">
|
||||
<span class="w-2 h-2 rounded-full heartbeat"
|
||||
:class="connected ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||
<span x-text="connected ? 'Connected' : 'Disconnected'"></span>
|
||||
</div>
|
||||
<span class="text-xs text-muted font-mono">{{ now()->format('H:i') }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="p-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Core App</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
padding: 32px;
|
||||
}
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 { font-size: 32px; margin-bottom: 8px; }
|
||||
h2 { font-size: 20px; margin-bottom: 16px; color: #8b949e; font-weight: 400; }
|
||||
.accent { color: #39d0d8; }
|
||||
.subtitle { color: #8b949e; font-size: 16px; margin-bottom: 24px; }
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
.info-item {
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.info-item__label { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.info-item__value { font-size: 14px; margin-top: 4px; font-family: monospace; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #238636;
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.counter { text-align: center; }
|
||||
.counter__display {
|
||||
font-size: 72px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #39d0d8;
|
||||
line-height: 1;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.counter__controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
.counter__hint {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px 32px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn:active { transform: scale(0.96); }
|
||||
.btn--primary {
|
||||
background: #238636;
|
||||
color: #fff;
|
||||
border-color: #2ea043;
|
||||
}
|
||||
.btn--primary:hover { background: #2ea043; }
|
||||
.btn--secondary {
|
||||
background: #21262d;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.btn--secondary:hover { background: #30363d; }
|
||||
</style>
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body>
|
||||
{{ $slot }}
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<x-dashboard-layout title="Live Activity">
|
||||
<livewire:dashboard.activity-feed />
|
||||
</x-dashboard-layout>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<x-dashboard-layout title="Agent Fleet">
|
||||
<livewire:dashboard.agent-fleet />
|
||||
</x-dashboard-layout>
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<x-dashboard-layout title="Dashboard">
|
||||
{{-- Metrics overview at top --}}
|
||||
<section class="mb-8">
|
||||
<livewire:dashboard.metrics />
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{{-- Left column: Agent fleet + Human actions --}}
|
||||
<div class="xl:col-span-2 space-y-6">
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Agent Fleet</h2>
|
||||
<livewire:dashboard.agent-fleet />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Job Queue</h2>
|
||||
<livewire:dashboard.job-queue />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{-- Right column: Actions + Activity --}}
|
||||
<div class="space-y-6">
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Human Actions</h2>
|
||||
<livewire:dashboard.human-actions />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Live Activity</h2>
|
||||
<livewire:dashboard.activity-feed />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dashboard-layout>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<x-dashboard-layout title="Job Queue">
|
||||
<livewire:dashboard.job-queue />
|
||||
</x-dashboard-layout>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<div class="counter">
|
||||
<div class="counter__display">{{ $count }}</div>
|
||||
<div class="counter__controls">
|
||||
<button wire:click="decrement" class="btn btn--secondary">−</button>
|
||||
<button wire:click="increment" class="btn btn--primary">+</button>
|
||||
</div>
|
||||
<p class="counter__hint">Livewire {{ \Livewire\Livewire::VERSION }} · Server-rendered, no page reload</p>
|
||||
</div>
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
<div wire:poll.3s="loadEntries">
|
||||
{{-- Filters --}}
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<select wire:model.live="agentFilter"
|
||||
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||
<option value="all">All agents</option>
|
||||
<option value="Athena">Athena</option>
|
||||
<option value="Virgil">Virgil</option>
|
||||
<option value="Clotho">Clotho</option>
|
||||
<option value="Charon">Charon</option>
|
||||
</select>
|
||||
<select wire:model.live="typeFilter"
|
||||
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||
<option value="all">All types</option>
|
||||
<option value="code_write">Code write</option>
|
||||
<option value="tool_call">Tool call</option>
|
||||
<option value="test_run">Test run</option>
|
||||
<option value="pr_created">PR created</option>
|
||||
<option value="git_push">Git push</option>
|
||||
<option value="question">Question</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-xs text-muted cursor-pointer">
|
||||
<input type="checkbox" wire:model.live="showOnlyQuestions"
|
||||
class="rounded border-border bg-surface-overlay text-accent focus:ring-accent">
|
||||
Waiting for answer only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Feed --}}
|
||||
<div class="space-y-2 max-h-[600px] overflow-y-auto scrollbar-thin">
|
||||
@forelse ($this->filteredEntries as $entry)
|
||||
<div class="bg-surface-raised border rounded-lg px-4 py-3 transition
|
||||
{{ $entry['is_question'] ? 'border-yellow-500/50 bg-yellow-500/5' : 'border-border' }}">
|
||||
<div class="flex items-start gap-3">
|
||||
{{-- Type icon --}}
|
||||
@php
|
||||
$typeIcons = [
|
||||
'code_write' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
|
||||
'tool_call' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
|
||||
'test_run' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
|
||||
'pr_created' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>',
|
||||
'git_push' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>',
|
||||
'question' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/>',
|
||||
];
|
||||
$iconPath = $typeIcons[$entry['type']] ?? $typeIcons['tool_call'];
|
||||
$iconColor = $entry['is_question'] ? 'text-yellow-400' : 'text-muted';
|
||||
@endphp
|
||||
<svg class="w-4 h-4 mt-0.5 shrink-0 {{ $iconColor }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">{!! $iconPath !!}</svg>
|
||||
|
||||
{{-- Content --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-xs font-semibold text-gray-300">{{ $entry['agent'] }}</span>
|
||||
<span class="text-[10px] text-muted font-mono">{{ $entry['job'] }}</span>
|
||||
@if ($entry['is_question'])
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400 font-medium">NEEDS ANSWER</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 leading-relaxed">{{ $entry['message'] }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Timestamp --}}
|
||||
<span class="text-[11px] text-muted shrink-0">
|
||||
{{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans(short: true) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-8 text-muted text-sm">No activity matching filters.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<div wire:poll.5s="loadAgents">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
@foreach ($agents as $agent)
|
||||
<div wire:click="selectAgent('{{ $agent['id'] }}')"
|
||||
class="bg-surface-raised border rounded-lg p-4 cursor-pointer transition hover:border-accent
|
||||
{{ $selectedAgent === $agent['id'] ? 'border-accent' : 'border-border' }}">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full heartbeat
|
||||
{{ $agent['heartbeat'] === 'green' ? 'bg-green-500' : ($agent['heartbeat'] === 'yellow' ? 'bg-yellow-500' : 'bg-red-500') }}"></span>
|
||||
<span class="font-semibold text-sm">{{ $agent['name'] }}</span>
|
||||
</div>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider
|
||||
{{ $agent['status'] === 'working' ? 'bg-blue-500/20 text-blue-400' : ($agent['status'] === 'idle' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400') }}">
|
||||
{{ $agent['status'] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Info --}}
|
||||
<div class="space-y-1.5 text-xs text-muted">
|
||||
<div class="flex justify-between">
|
||||
<span>Host</span>
|
||||
<span class="text-gray-300 font-mono">{{ $agent['host'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Model</span>
|
||||
<span class="text-gray-300 font-mono text-[11px]">{{ $agent['model'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Uptime</span>
|
||||
<span class="text-gray-300">{{ $agent['uptime'] }}</span>
|
||||
</div>
|
||||
@if ($agent['job'])
|
||||
<div class="flex justify-between">
|
||||
<span>Job</span>
|
||||
<span class="text-accent text-[11px]">{{ $agent['job'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Expanded detail --}}
|
||||
@if ($selectedAgent === $agent['id'])
|
||||
<div class="mt-3 pt-3 border-t border-border space-y-1.5 text-xs text-muted">
|
||||
<div class="flex justify-between">
|
||||
<span>Tokens today</span>
|
||||
<span class="text-gray-300">{{ number_format($agent['tokens_today']) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Jobs completed</span>
|
||||
<span class="text-gray-300">{{ $agent['jobs_completed'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
<div wire:poll.3s="loadPending">
|
||||
{{-- Pending questions --}}
|
||||
@if (count($pendingQuestions) > 0)
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-yellow-500 heartbeat"></span>
|
||||
Agent Questions ({{ count($pendingQuestions) }})
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
@foreach ($pendingQuestions as $q)
|
||||
<div class="bg-yellow-500/5 border border-yellow-500/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-yellow-400">{{ $q['agent'] }}</span>
|
||||
<span class="text-[10px] text-muted font-mono">{{ $q['job'] }}</span>
|
||||
<span class="text-[10px] text-muted">{{ \Carbon\Carbon::parse($q['asked_at'])->diffForHumans(short: true) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-300 mb-2">{{ $q['question'] }}</p>
|
||||
@if (!empty($q['context']))
|
||||
<p class="text-xs text-muted mb-3">{{ $q['context'] }}</p>
|
||||
@endif
|
||||
|
||||
@if ($answeringId === $q['id'])
|
||||
<div class="mt-3">
|
||||
<textarea wire:model="answerText"
|
||||
rows="3"
|
||||
placeholder="Type your answer..."
|
||||
class="w-full bg-surface-overlay border border-border rounded-md px-3 py-2 text-sm text-gray-300 placeholder-muted focus:border-accent focus:outline-none resize-none"></textarea>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button wire:click="submitAnswer"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-accent text-surface hover:opacity-90 transition">
|
||||
Send Answer
|
||||
</button>
|
||||
<button wire:click="cancelAnswer"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-surface-overlay text-muted hover:text-white border border-border transition">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<button wire:click="startAnswer('{{ $q['id'] }}')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition">
|
||||
Answer
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Review gates --}}
|
||||
@if (count($reviewGates) > 0)
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-purple-500 heartbeat"></span>
|
||||
Review Gates ({{ count($reviewGates) }})
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
@foreach ($reviewGates as $gate)
|
||||
<div class="bg-surface-raised border border-purple-500/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-purple-400">{{ $gate['agent'] }}</span>
|
||||
<span class="text-[10px] text-muted font-mono">{{ $gate['job'] }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium uppercase">{{ str_replace('_', ' ', $gate['type']) }}</span>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-300 mb-1">{{ $gate['title'] }}</p>
|
||||
<p class="text-xs text-muted mb-3">{{ $gate['description'] }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button wire:click="approveGate('{{ $gate['id'] }}')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 transition">
|
||||
Approve
|
||||
</button>
|
||||
<button wire:click="rejectGate('{{ $gate['id'] }}')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (count($pendingQuestions) === 0 && count($reviewGates) === 0)
|
||||
<div class="text-center py-12 text-muted">
|
||||
<svg class="w-8 h-8 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm">No pending actions. All agents are autonomous.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<div wire:poll.5s="loadJobs">
|
||||
{{-- Filters --}}
|
||||
<div class="flex flex-wrap gap-3 mb-4">
|
||||
<select wire:model.live="statusFilter"
|
||||
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||
<option value="all">All statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select wire:model.live="agentFilter"
|
||||
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||
<option value="all">All agents</option>
|
||||
<option value="Athena">Athena</option>
|
||||
<option value="Virgil">Virgil</option>
|
||||
<option value="Clotho">Clotho</option>
|
||||
<option value="Charon">Charon</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="bg-surface-raised border border-border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-xs text-muted uppercase tracking-wider">
|
||||
<th class="text-left px-4 py-3 font-medium">Job</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Issue</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Agent</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Priority</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Queued</th>
|
||||
<th class="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
@forelse ($this->filteredJobs as $job)
|
||||
<tr class="hover:bg-surface-overlay/50 transition">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-mono text-xs text-muted">{{ $job['id'] }}</div>
|
||||
<div class="text-xs text-gray-300 mt-0.5 truncate max-w-[200px]">{{ $job['title'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-accent font-mono text-xs">{{ $job['issue'] }}</span>
|
||||
<div class="text-[11px] text-muted">{{ $job['repo'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
{{ $job['agent'] ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@php
|
||||
$statusColors = [
|
||||
'queued' => 'bg-yellow-500/20 text-yellow-400',
|
||||
'in_progress' => 'bg-blue-500/20 text-blue-400',
|
||||
'review' => 'bg-purple-500/20 text-purple-400',
|
||||
'completed' => 'bg-green-500/20 text-green-400',
|
||||
'failed' => 'bg-red-500/20 text-red-400',
|
||||
'cancelled' => 'bg-gray-500/20 text-gray-400',
|
||||
];
|
||||
@endphp
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider {{ $statusColors[$job['status']] ?? '' }}">
|
||||
{{ str_replace('_', ' ', $job['status']) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs font-mono text-muted">P{{ $job['priority'] }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-muted">
|
||||
{{ \Carbon\Carbon::parse($job['queued_at'])->diffForHumans(short: true) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@if (in_array($job['status'], ['queued', 'in_progress']))
|
||||
<button wire:click="cancelJob('{{ $job['id'] }}')"
|
||||
class="text-[11px] px-2 py-1 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20 transition">
|
||||
Cancel
|
||||
</button>
|
||||
@endif
|
||||
@if (in_array($job['status'], ['failed', 'cancelled']))
|
||||
<button wire:click="retryJob('{{ $job['id'] }}')"
|
||||
class="text-[11px] px-2 py-1 rounded bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition">
|
||||
Retry
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-8 text-center text-muted text-sm">No jobs match the selected filters.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
<div wire:poll.10s="loadMetrics">
|
||||
{{-- Stat cards --}}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
|
||||
@php
|
||||
$statCards = [
|
||||
['label' => 'Jobs Completed', 'value' => $stats['jobs_completed'], 'icon' => 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-green-400'],
|
||||
['label' => 'PRs Merged', 'value' => $stats['prs_merged'], 'icon' => 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4', 'color' => 'text-purple-400'],
|
||||
['label' => 'Tokens Used', 'value' => number_format($stats['tokens_used']), 'icon' => 'M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z', 'color' => 'text-blue-400'],
|
||||
['label' => 'Cost Today', 'value' => '$' . number_format($stats['cost_today'], 2), 'icon' => 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-yellow-400'],
|
||||
['label' => 'Active Agents', 'value' => $stats['active_agents'], 'icon' => 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z', 'color' => 'text-accent'],
|
||||
['label' => 'Queue Depth', 'value' => $stats['queue_depth'], 'icon' => 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', 'color' => 'text-orange-400'],
|
||||
];
|
||||
@endphp
|
||||
@foreach ($statCards as $card)
|
||||
<div class="bg-surface-raised border border-border rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-4 h-4 {{ $card['color'] }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $card['icon'] }}"/>
|
||||
</svg>
|
||||
<span class="text-[11px] text-muted uppercase tracking-wider">{{ $card['label'] }}</span>
|
||||
</div>
|
||||
<div class="text-xl font-bold font-mono {{ $card['color'] }}">{{ $card['value'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Budget gauge --}}
|
||||
<div class="bg-surface-raised border border-border rounded-lg p-5">
|
||||
<h3 class="text-sm font-semibold mb-4">Budget</h3>
|
||||
<div class="flex items-end gap-3 mb-3">
|
||||
<span class="text-3xl font-bold font-mono text-accent">${{ number_format($budgetUsed, 2) }}</span>
|
||||
<span class="text-sm text-muted mb-1">/ ${{ number_format($budgetLimit, 2) }}</span>
|
||||
</div>
|
||||
@php
|
||||
$pct = $budgetLimit > 0 ? min(100, ($budgetUsed / $budgetLimit) * 100) : 0;
|
||||
$barColor = $pct > 80 ? 'bg-red-500' : ($pct > 60 ? 'bg-yellow-500' : 'bg-accent');
|
||||
@endphp
|
||||
<div class="w-full h-3 bg-surface-overlay rounded-full overflow-hidden">
|
||||
<div class="{{ $barColor }} h-full rounded-full transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
<div class="text-xs text-muted mt-2">{{ number_format($pct, 0) }}% of daily budget used</div>
|
||||
</div>
|
||||
|
||||
{{-- Cost breakdown by model --}}
|
||||
<div class="bg-surface-raised border border-border rounded-lg p-5">
|
||||
<h3 class="text-sm font-semibold mb-4">Cost by Model</h3>
|
||||
<div class="space-y-3">
|
||||
@foreach ($costBreakdown as $model)
|
||||
@php
|
||||
$modelPct = $budgetUsed > 0 ? ($model['cost'] / $budgetUsed) * 100 : 0;
|
||||
$modelColors = [
|
||||
'claude-opus-4-6' => 'bg-purple-500',
|
||||
'claude-sonnet-4-5' => 'bg-blue-500',
|
||||
'claude-haiku-4-5' => 'bg-green-500',
|
||||
];
|
||||
$barCol = $modelColors[$model['model']] ?? 'bg-gray-500';
|
||||
@endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="font-mono text-gray-300">{{ $model['model'] }}</span>
|
||||
<span class="text-muted">${{ number_format($model['cost'], 2) }} ({{ number_format($model['tokens']) }} tokens)</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-surface-overlay rounded-full overflow-hidden">
|
||||
<div class="{{ $barCol }} h-full rounded-full transition-all duration-500" style="width: {{ $modelPct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Throughput chart --}}
|
||||
<div class="bg-surface-raised border border-border rounded-lg p-5 mt-6"
|
||||
x-data="{
|
||||
chart: null,
|
||||
init() {
|
||||
this.chart = new ApexCharts(this.$refs.chart, {
|
||||
chart: {
|
||||
type: 'area',
|
||||
height: 240,
|
||||
background: 'transparent',
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
},
|
||||
theme: { mode: 'dark' },
|
||||
colors: ['#39d0d8', '#8b5cf6'],
|
||||
series: [
|
||||
{ name: 'Jobs', data: {{ json_encode(array_column($throughputData, 'jobs')) }} },
|
||||
{ name: 'Tokens (k)', data: {{ json_encode(array_map(fn($t) => round($t / 1000, 1), array_column($throughputData, 'tokens'))) }} },
|
||||
],
|
||||
xaxis: {
|
||||
categories: {{ json_encode(array_column($throughputData, 'hour')) }},
|
||||
labels: { style: { colors: '#8b949e', fontSize: '11px' } },
|
||||
},
|
||||
yaxis: [
|
||||
{ labels: { style: { colors: '#39d0d8' } }, title: { text: 'Jobs', style: { color: '#39d0d8' } } },
|
||||
{ opposite: true, labels: { style: { colors: '#8b5cf6' } }, title: { text: 'Tokens (k)', style: { color: '#8b5cf6' } } },
|
||||
],
|
||||
grid: { borderColor: '#21262d', strokeDashArray: 3 },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } },
|
||||
dataLabels: { enabled: false },
|
||||
legend: { labels: { colors: '#8b949e' } },
|
||||
tooltip: { theme: 'dark' },
|
||||
});
|
||||
this.chart.render();
|
||||
}
|
||||
}">
|
||||
<h3 class="text-sm font-semibold mb-4">Throughput</h3>
|
||||
<div x-ref="chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<x-layout>
|
||||
<div class="card">
|
||||
<h1><span class="accent">Core App</span></h1>
|
||||
<p class="subtitle">Laravel {{ app()->version() }} running inside a native desktop window</p>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="info-item__label">PHP</div>
|
||||
<div class="info-item__value">{{ PHP_VERSION }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item__label">Thread Safety</div>
|
||||
<div class="info-item__value">{{ PHP_ZTS ? 'ZTS (Yes)' : 'NTS (No)' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item__label">SAPI</div>
|
||||
<div class="info-item__value">{{ php_sapi_name() }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item__label">Platform</div>
|
||||
<div class="info-item__value">{{ PHP_OS }} {{ php_uname('m') }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item__label">Database</div>
|
||||
<div class="info-item__value">SQLite {{ \SQLite3::version()['versionString'] }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item__label">Mode</div>
|
||||
<div class="info-item__value">{{ env('FRANKENPHP_WORKER') ? 'Octane Worker' : 'Standard' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="badge">Single Binary · No Server · No Config</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Livewire Reactivity Test</h2>
|
||||
<livewire:counter />
|
||||
</div>
|
||||
</x-layout>
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AgentAllowance;
|
||||
use App\Models\ModelQuota;
|
||||
use App\Models\RepoLimit;
|
||||
use App\Services\AllowanceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowance API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Endpoints for managing agent quotas, checking allowances, and recording
|
||||
| usage. Protected endpoints use QuotaMiddleware for enforcement.
|
||||
|
|
||||
*/
|
||||
|
||||
// Health check for quota service
|
||||
Route::get('/allowances/health', fn () => response()->json(['status' => 'ok']));
|
||||
|
||||
// Agent allowance CRUD
|
||||
Route::prefix('allowances/agents')->group(function () {
|
||||
Route::get('/', function () {
|
||||
return AgentAllowance::all();
|
||||
});
|
||||
|
||||
Route::get('/{agentId}', function (string $agentId) {
|
||||
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
|
||||
|
||||
if (! $allowance) {
|
||||
return response()->json(['error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
return $allowance;
|
||||
});
|
||||
|
||||
Route::post('/', function (Request $request) {
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|unique:agent_allowances,agent_id',
|
||||
'daily_token_limit' => 'integer|min:0',
|
||||
'daily_job_limit' => 'integer|min:0',
|
||||
'concurrent_jobs' => 'integer|min:0',
|
||||
'max_job_duration_minutes' => 'integer|min:0',
|
||||
'model_allowlist' => 'array',
|
||||
'model_allowlist.*' => 'string',
|
||||
]);
|
||||
|
||||
return AgentAllowance::create($validated);
|
||||
});
|
||||
|
||||
Route::put('/{agentId}', function (Request $request, string $agentId) {
|
||||
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
|
||||
|
||||
if (! $allowance) {
|
||||
return response()->json(['error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'daily_token_limit' => 'integer|min:0',
|
||||
'daily_job_limit' => 'integer|min:0',
|
||||
'concurrent_jobs' => 'integer|min:0',
|
||||
'max_job_duration_minutes' => 'integer|min:0',
|
||||
'model_allowlist' => 'array',
|
||||
'model_allowlist.*' => 'string',
|
||||
]);
|
||||
|
||||
$allowance->update($validated);
|
||||
|
||||
return $allowance;
|
||||
});
|
||||
|
||||
Route::delete('/{agentId}', function (string $agentId) {
|
||||
AgentAllowance::where('agent_id', $agentId)->delete();
|
||||
|
||||
return response()->json(['status' => 'deleted']);
|
||||
});
|
||||
});
|
||||
|
||||
// Quota check endpoint
|
||||
Route::get('/allowances/check/{agentId}', function (Request $request, string $agentId, AllowanceService $svc) {
|
||||
$model = $request->query('model', '');
|
||||
|
||||
return response()->json($svc->check($agentId, $model));
|
||||
});
|
||||
|
||||
// Usage reporting endpoint
|
||||
Route::post('/allowances/usage', function (Request $request, AllowanceService $svc) {
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string',
|
||||
'job_id' => 'required|string',
|
||||
'model' => 'nullable|string',
|
||||
'tokens_in' => 'integer|min:0',
|
||||
'tokens_out' => 'integer|min:0',
|
||||
'event' => 'required|in:job_started,job_completed,job_failed,job_cancelled',
|
||||
'timestamp' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$svc->recordUsage($validated);
|
||||
|
||||
return response()->json(['status' => 'recorded']);
|
||||
});
|
||||
|
||||
// Daily reset endpoint
|
||||
Route::post('/allowances/reset/{agentId}', function (string $agentId, AllowanceService $svc) {
|
||||
$svc->resetAgent($agentId);
|
||||
|
||||
return response()->json(['status' => 'reset']);
|
||||
});
|
||||
|
||||
// Model quota management
|
||||
Route::prefix('allowances/models')->group(function () {
|
||||
Route::get('/', fn () => ModelQuota::all());
|
||||
|
||||
Route::post('/', function (Request $request) {
|
||||
$validated = $request->validate([
|
||||
'model' => 'required|string|unique:model_quotas,model',
|
||||
'daily_token_budget' => 'integer|min:0',
|
||||
'hourly_rate_limit' => 'integer|min:0',
|
||||
'cost_ceiling' => 'integer|min:0',
|
||||
]);
|
||||
|
||||
return ModelQuota::create($validated);
|
||||
});
|
||||
|
||||
Route::put('/{model}', function (Request $request, string $model) {
|
||||
$quota = ModelQuota::where('model', $model)->first();
|
||||
|
||||
if (! $quota) {
|
||||
return response()->json(['error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'daily_token_budget' => 'integer|min:0',
|
||||
'hourly_rate_limit' => 'integer|min:0',
|
||||
'cost_ceiling' => 'integer|min:0',
|
||||
]);
|
||||
|
||||
$quota->update($validated);
|
||||
|
||||
return $quota;
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
|
||||
// Agentic Dashboard
|
||||
Route::get('/dashboard', fn () => view('dashboard.index'))->name('dashboard');
|
||||
Route::get('/dashboard/agents', fn () => view('dashboard.agents'))->name('dashboard.agents');
|
||||
Route::get('/dashboard/jobs', fn () => view('dashboard.jobs'))->name('dashboard.jobs');
|
||||
Route::get('/dashboard/activity', fn () => view('dashboard.activity'))->name('dashboard.activity');
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\Forgejo;
|
||||
|
||||
use App\Services\Forgejo\ForgejoClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
class ForgejoClientTest extends TestCase
|
||||
{
|
||||
private const BASE_URL = 'https://forge.test';
|
||||
private const TOKEN = 'test-token-abc123';
|
||||
|
||||
// ---- Construction ----
|
||||
|
||||
public function test_constructor_good(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN);
|
||||
|
||||
$this->assertSame(self::BASE_URL, $client->baseUrl());
|
||||
}
|
||||
|
||||
public function test_constructor_bad_empty_token(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('API token is required');
|
||||
|
||||
new ForgejoClient(self::BASE_URL, '');
|
||||
}
|
||||
|
||||
// ---- GET ----
|
||||
|
||||
public function test_get_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo' => Http::response(['id' => 1, 'name' => 'repo'], 200),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$result = $client->get('/repos/owner/repo');
|
||||
|
||||
$this->assertSame(1, $result['id']);
|
||||
$this->assertSame('repo', $result['name']);
|
||||
}
|
||||
|
||||
public function test_get_bad_server_error(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo' => Http::response('Internal Server Error', 500),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Forgejo API error [500]');
|
||||
|
||||
$client->get('/repos/owner/repo');
|
||||
}
|
||||
|
||||
// ---- POST ----
|
||||
|
||||
public function test_post_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/issues' => Http::response(['number' => 42], 201),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$result = $client->post('/repos/owner/repo/issues', ['title' => 'Bug']);
|
||||
|
||||
$this->assertSame(42, $result['number']);
|
||||
}
|
||||
|
||||
// ---- PATCH ----
|
||||
|
||||
public function test_patch_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/issues/1' => Http::response(['state' => 'closed'], 200),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$result = $client->patch('/repos/owner/repo/issues/1', ['state' => 'closed']);
|
||||
|
||||
$this->assertSame('closed', $result['state']);
|
||||
}
|
||||
|
||||
// ---- PUT ----
|
||||
|
||||
public function test_put_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/teams/5/members/alice' => Http::response([], 204),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$result = $client->put('/teams/5/members/alice');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
}
|
||||
|
||||
// ---- DELETE ----
|
||||
|
||||
public function test_delete_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/branches/old' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
|
||||
// Should not throw
|
||||
$client->delete('/repos/owner/repo/branches/old');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_delete_bad_not_found(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/branches/gone' => Http::response('Not Found', 404),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('failed [404]');
|
||||
|
||||
$client->delete('/repos/owner/repo/branches/gone');
|
||||
}
|
||||
|
||||
// ---- getRaw ----
|
||||
|
||||
public function test_getRaw_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/pulls/1.diff' => Http::response(
|
||||
"diff --git a/file.txt b/file.txt\n",
|
||||
200,
|
||||
['Content-Type' => 'text/plain'],
|
||||
),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$diff = $client->getRaw('/repos/owner/repo/pulls/1.diff');
|
||||
|
||||
$this->assertStringContainsString('diff --git', $diff);
|
||||
}
|
||||
|
||||
// ---- Pagination ----
|
||||
|
||||
public function test_paginate_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/orgs/myorg/repos?page=1&limit=2' => Http::response(
|
||||
[['id' => 1], ['id' => 2]],
|
||||
200,
|
||||
['x-total-count' => '3'],
|
||||
),
|
||||
'forge.test/api/v1/orgs/myorg/repos?page=2&limit=2' => Http::response(
|
||||
[['id' => 3]],
|
||||
200,
|
||||
['x-total-count' => '3'],
|
||||
),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$repos = $client->paginate('/orgs/myorg/repos', [], 2);
|
||||
|
||||
$this->assertCount(3, $repos);
|
||||
$this->assertSame(1, $repos[0]['id']);
|
||||
$this->assertSame(3, $repos[2]['id']);
|
||||
}
|
||||
|
||||
public function test_paginate_good_empty(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/orgs/empty/repos?page=1&limit=50' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$repos = $client->paginate('/orgs/empty/repos');
|
||||
|
||||
$this->assertSame([], $repos);
|
||||
}
|
||||
|
||||
// ---- Auth header ----
|
||||
|
||||
public function test_auth_header_sent(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/user' => Http::response(['login' => 'bot'], 200),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$client->get('/user');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->hasHeader('Authorization', 'token ' . self::TOKEN);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\Forgejo;
|
||||
|
||||
use App\Services\Forgejo\ForgejoService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
class ForgejoServiceTest extends TestCase
|
||||
{
|
||||
private const INSTANCES = [
|
||||
'forge' => ['url' => 'https://forge.test', 'token' => 'tok-forge'],
|
||||
'dev' => ['url' => 'https://dev.test', 'token' => 'tok-dev'],
|
||||
];
|
||||
|
||||
private function service(): ForgejoService
|
||||
{
|
||||
return new ForgejoService(
|
||||
instances: self::INSTANCES,
|
||||
defaultInstance: 'forge',
|
||||
timeout: 5,
|
||||
retryTimes: 0,
|
||||
retrySleep: 0,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
public function test_instances_good(): void
|
||||
{
|
||||
$svc = $this->service();
|
||||
|
||||
$this->assertSame(['forge', 'dev'], $svc->instances());
|
||||
}
|
||||
|
||||
public function test_instances_skips_empty_token(): void
|
||||
{
|
||||
$svc = new ForgejoService(
|
||||
instances: [
|
||||
'forge' => ['url' => 'https://forge.test', 'token' => 'tok'],
|
||||
'qa' => ['url' => 'https://qa.test', 'token' => ''],
|
||||
],
|
||||
);
|
||||
|
||||
$this->assertSame(['forge'], $svc->instances());
|
||||
}
|
||||
|
||||
public function test_client_bad_unknown_instance(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("instance 'nope' is not configured");
|
||||
|
||||
$this->service()->client('nope');
|
||||
}
|
||||
|
||||
// ---- Issues ----
|
||||
|
||||
public function test_createIssue_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues' => Http::response([
|
||||
'number' => 99,
|
||||
'title' => 'New bug',
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createIssue('org', 'repo', 'New bug', 'Description');
|
||||
|
||||
$this->assertSame(99, $result['number']);
|
||||
|
||||
Http::assertSent(fn ($r) => $r['title'] === 'New bug' && $r['body'] === 'Description');
|
||||
}
|
||||
|
||||
public function test_createIssue_good_with_labels_and_assignee(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues' => Http::response(['number' => 1], 201),
|
||||
]);
|
||||
|
||||
$this->service()->createIssue('org', 'repo', 'Task', assignee: 'alice', labels: [1, 2]);
|
||||
|
||||
Http::assertSent(fn ($r) => $r['assignees'] === ['alice'] && $r['labels'] === [1, 2]);
|
||||
}
|
||||
|
||||
public function test_closeIssue_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues/5' => Http::response(['state' => 'closed'], 200),
|
||||
]);
|
||||
|
||||
$result = $this->service()->closeIssue('org', 'repo', 5);
|
||||
|
||||
$this->assertSame('closed', $result['state']);
|
||||
}
|
||||
|
||||
public function test_addComment_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues/5/comments' => Http::response(['id' => 100], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->addComment('org', 'repo', 5, 'LGTM');
|
||||
|
||||
$this->assertSame(100, $result['id']);
|
||||
}
|
||||
|
||||
public function test_listIssues_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues*' => Http::response([
|
||||
['number' => 1],
|
||||
['number' => 2],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$issues = $this->service()->listIssues('org', 'repo');
|
||||
|
||||
$this->assertCount(2, $issues);
|
||||
}
|
||||
|
||||
// ---- Pull Requests ----
|
||||
|
||||
public function test_createPR_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/pulls' => Http::response([
|
||||
'number' => 10,
|
||||
'title' => 'Feature X',
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createPR('org', 'repo', 'feat/x', 'main', 'Feature X');
|
||||
|
||||
$this->assertSame(10, $result['number']);
|
||||
}
|
||||
|
||||
public function test_mergePR_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/pulls/10/merge' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
// Should not throw
|
||||
$this->service()->mergePR('org', 'repo', 10, 'squash');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_getPRDiff_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/pulls/10.diff' => Http::response(
|
||||
"diff --git a/f.go b/f.go\n+new line\n",
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$diff = $this->service()->getPRDiff('org', 'repo', 10);
|
||||
|
||||
$this->assertStringContainsString('diff --git', $diff);
|
||||
}
|
||||
|
||||
// ---- Repositories ----
|
||||
|
||||
public function test_getRepo_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/core' => Http::response(['full_name' => 'org/core'], 200),
|
||||
]);
|
||||
|
||||
$result = $this->service()->getRepo('org', 'core');
|
||||
|
||||
$this->assertSame('org/core', $result['full_name']);
|
||||
}
|
||||
|
||||
public function test_createBranch_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/branches' => Http::response(['name' => 'feat/y'], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createBranch('org', 'repo', 'feat/y', 'main');
|
||||
|
||||
$this->assertSame('feat/y', $result['name']);
|
||||
|
||||
Http::assertSent(fn ($r) =>
|
||||
$r['new_branch_name'] === 'feat/y' && $r['old_branch_name'] === 'main'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_deleteBranch_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/branches/old' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
$this->service()->deleteBranch('org', 'repo', 'old');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// ---- User / Token Management ----
|
||||
|
||||
public function test_createUser_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/admin/users' => Http::response(['login' => 'bot'], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createUser('bot', 'bot@test.io', 's3cret');
|
||||
|
||||
$this->assertSame('bot', $result['login']);
|
||||
|
||||
Http::assertSent(fn ($r) =>
|
||||
$r['username'] === 'bot'
|
||||
&& $r['must_change_password'] === false
|
||||
);
|
||||
}
|
||||
|
||||
public function test_createToken_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/users/bot/tokens' => Http::response(['sha1' => 'abc123'], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createToken('bot', 'ci-token', ['repo', 'user']);
|
||||
|
||||
$this->assertSame('abc123', $result['sha1']);
|
||||
}
|
||||
|
||||
public function test_revokeToken_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/users/bot/tokens/42' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
$this->service()->revokeToken('bot', 42);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// ---- Multi-instance routing ----
|
||||
|
||||
public function test_explicit_instance_routing(): void
|
||||
{
|
||||
Http::fake([
|
||||
'dev.test/api/v1/repos/org/repo' => Http::response(['full_name' => 'org/repo'], 200),
|
||||
]);
|
||||
|
||||
$result = $this->service()->getRepo('org', 'repo', instance: 'dev');
|
||||
|
||||
$this->assertSame('org/repo', $result['full_name']);
|
||||
|
||||
Http::assertSent(fn ($r) => str_contains($r->url(), 'dev.test'));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
// Package main provides the Core App — a native desktop application
|
||||
// embedding Laravel via FrankenPHP inside a Wails v3 window.
|
||||
//
|
||||
// A single Go binary that boots the PHP runtime, extracts the embedded
|
||||
// Laravel application, and serves it through FrankenPHP's ServeHTTP into
|
||||
// a native webview via Wails v3's AssetOptions.Handler.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
|
||||
"forge.lthn.ai/core/go/cmd/core-app/icons"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Set up PHP handler (extracts Laravel, prepares env, inits FrankenPHP).
|
||||
handler, env, cleanup, err := NewPHPHandler()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialise PHP handler: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// Create the app service and native bridge.
|
||||
appService := NewAppService(env)
|
||||
bridge, err := NewNativeBridge(appService)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start native bridge: %v", err)
|
||||
}
|
||||
defer bridge.Shutdown(context.Background())
|
||||
|
||||
// Inject the bridge URL into the Laravel .env so PHP can call Go.
|
||||
if err := appendEnv(handler.laravelRoot, "NATIVE_BRIDGE_URL", bridge.URL()); err != nil {
|
||||
log.Printf("Warning: couldn't inject bridge URL into .env: %v", err)
|
||||
}
|
||||
|
||||
app := application.New(application.Options{
|
||||
Name: "Core App",
|
||||
Description: "Host UK Native App — Laravel powered by FrankenPHP",
|
||||
Services: []application.Service{
|
||||
application.NewService(appService),
|
||||
},
|
||||
Assets: application.AssetOptions{
|
||||
Handler: handler,
|
||||
},
|
||||
Mac: application.MacOptions{
|
||||
ActivationPolicy: application.ActivationPolicyAccessory,
|
||||
},
|
||||
})
|
||||
|
||||
appService.app = app
|
||||
|
||||
setupSystemTray(app)
|
||||
|
||||
// Main application window
|
||||
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "main",
|
||||
Title: "Core App",
|
||||
Width: 1200,
|
||||
Height: 800,
|
||||
URL: "/",
|
||||
BackgroundColour: application.NewRGB(13, 17, 23),
|
||||
})
|
||||
|
||||
log.Println("Starting Core App...")
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// setupSystemTray configures the system tray icon and menu.
|
||||
func setupSystemTray(app *application.App) {
|
||||
systray := app.SystemTray.New()
|
||||
systray.SetTooltip("Core App")
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
systray.SetTemplateIcon(icons.TrayTemplate)
|
||||
} else {
|
||||
systray.SetDarkModeIcon(icons.TrayDark)
|
||||
systray.SetIcon(icons.TrayLight)
|
||||
}
|
||||
|
||||
trayMenu := app.Menu.New()
|
||||
|
||||
trayMenu.Add("Open Core App").OnClick(func(ctx *application.Context) {
|
||||
if w, ok := app.Window.Get("main"); ok {
|
||||
w.Show()
|
||||
w.Focus()
|
||||
}
|
||||
})
|
||||
|
||||
trayMenu.AddSeparator()
|
||||
|
||||
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
|
||||
app.Quit()
|
||||
})
|
||||
|
||||
systray.SetMenu(trayMenu)
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// NativeBridge provides a localhost HTTP API that PHP code can call
|
||||
// to access native desktop capabilities (file dialogs, notifications, etc.).
|
||||
//
|
||||
// Livewire renders server-side in PHP, so it can't call Wails bindings
|
||||
// (window.go.*) directly. Instead, PHP makes HTTP requests to this bridge.
|
||||
// The bridge port is injected into Laravel's .env as NATIVE_BRIDGE_URL.
|
||||
type NativeBridge struct {
|
||||
server *http.Server
|
||||
port int
|
||||
app *AppService
|
||||
}
|
||||
|
||||
// NewNativeBridge creates and starts the bridge on a random available port.
|
||||
func NewNativeBridge(appService *AppService) (*NativeBridge, error) {
|
||||
mux := http.NewServeMux()
|
||||
bridge := &NativeBridge{app: appService}
|
||||
|
||||
// Register bridge endpoints
|
||||
mux.HandleFunc("POST /bridge/version", bridge.handleVersion)
|
||||
mux.HandleFunc("POST /bridge/data-dir", bridge.handleDataDir)
|
||||
mux.HandleFunc("POST /bridge/show-window", bridge.handleShowWindow)
|
||||
mux.HandleFunc("GET /bridge/health", bridge.handleHealth)
|
||||
|
||||
// Listen on a random available port (localhost only)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
|
||||
bridge.port = listener.Addr().(*net.TCPAddr).Port
|
||||
bridge.server = &http.Server{Handler: mux}
|
||||
|
||||
go func() {
|
||||
if err := bridge.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("Native bridge error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Native bridge listening on http://127.0.0.1:%d", bridge.port)
|
||||
return bridge, nil
|
||||
}
|
||||
|
||||
// Port returns the port the bridge is listening on.
|
||||
func (b *NativeBridge) Port() int {
|
||||
return b.port
|
||||
}
|
||||
|
||||
// URL returns the full base URL of the bridge.
|
||||
func (b *NativeBridge) URL() string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", b.port)
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the bridge server.
|
||||
func (b *NativeBridge) Shutdown(ctx context.Context) error {
|
||||
return b.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (b *NativeBridge) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (b *NativeBridge) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"version": b.app.GetVersion()})
|
||||
}
|
||||
|
||||
func (b *NativeBridge) handleDataDir(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"path": b.app.GetDataDir()})
|
||||
}
|
||||
|
||||
func (b *NativeBridge) handleShowWindow(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
b.app.ShowWindow(req.Name)
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
18
cmd/updater/.gitignore
vendored
18
cmd/updater/.gitignore
vendored
|
|
@ -1,18 +0,0 @@
|
|||
# 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*
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
.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"
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
//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
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
//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
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# 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).
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"name": "updater",
|
||||
"version": "1.2.3"
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
//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
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# 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
43
cmd/updater/ui/.gitignore
vendored
|
|
@ -1,43 +0,0 @@
|
|||
# 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
4
cmd/updater/ui/.vscode/extensions.json
vendored
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
cmd/updater/ui/.vscode/launch.json
vendored
20
cmd/updater/ui/.vscode/launch.json
vendored
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
// 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
42
cmd/updater/ui/.vscode/tasks.json
vendored
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
{
|
||||
"$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
9037
cmd/updater/ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,49 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue