refactor: move core-app, updater, vanity-import, community out of CLI
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 15s

- 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:
Snider 2026-02-21 21:33:15 +00:00
parent 1b99ea22f1
commit a9fe9fe04b
118 changed files with 1 additions and 23261 deletions

View file

@ -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"

View file

@ -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 95100% · 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 95100%. 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>

View file

@ -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.

View file

@ -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

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 => ../..

View file

@ -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=

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,5 +0,0 @@
/vendor/
/node_modules/
/.env
/bootstrap/cache/*.php
/storage/*.key

View file

@ -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);
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -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();
}
}

View file

@ -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',
];
}
}

View file

@ -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');
}
}

View file

@ -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',
];
}
}

View file

@ -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).
}
}
}

View file

@ -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
}
}

View file

@ -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() ?? [];
}
}

View file

@ -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');
}
}

View file

@ -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);

View file

@ -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();

View file

@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
return [
App\Providers\AppServiceProvider::class,
];

View file

@ -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"
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -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',
],
];

View 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_'),
];

View file

@ -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,
],
];

View file

@ -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),
];

View file

@ -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],
],
],
];

View file

@ -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,
];

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
return [
'paths' => [
resource_path('views'),
],
'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))),
];

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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());

View file

@ -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>

View file

@ -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>

View file

@ -1,3 +0,0 @@
<x-dashboard-layout title="Live Activity">
<livewire:dashboard.activity-feed />
</x-dashboard-layout>

View file

@ -1,3 +0,0 @@
<x-dashboard-layout title="Agent Fleet">
<livewire:dashboard.agent-fleet />
</x-dashboard-layout>

View file

@ -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>

View file

@ -1,3 +0,0 @@
<x-dashboard-layout title="Job Queue">
<livewire:dashboard.job-queue />
</x-dashboard-layout>

View file

@ -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">&minus;</button>
<button wire:click="increment" class="btn btn--primary">+</button>
</div>
<p class="counter__hint">Livewire {{ \Livewire\Livewire::VERSION }} &middot; Server-rendered, no page reload</p>
</div>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 &middot; No Server &middot; No Config</div>
</div>
<div class="card">
<h2>Livewire Reactivity Test</h2>
<livewire:counter />
</div>
</x-layout>

View file

@ -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;
});
});

View file

@ -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');

View file

@ -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);
});
}
}

View file

@ -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'));
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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*

View file

@ -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.

View file

@ -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"

View file

@ -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.

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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.

View file

@ -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.

View file

@ -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).

View file

@ -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.

View file

@ -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
}

View file

@ -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)
}
}
})
}
}

View file

@ -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)
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -1,4 +0,0 @@
{
"name": "updater",
"version": "1.2.3"
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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

View file

@ -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

View file

@ -1,4 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View file

@ -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"
}
]
}

View file

@ -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"
}
}
}
}
]
}

View file

@ -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.

View file

@ -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"
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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