Merge branch 'feat/frankenphp-native-app' into new
# Conflicts: # pkg/crypt/chachapoly/chachapoly.go # pkg/crypt/chachapoly/chachapoly_test.go # pkg/crypt/lthn/lthn.go # pkg/crypt/lthn/lthn_test.go # pkg/crypt/rsa/rsa.go # pkg/crypt/rsa/rsa_test.go # pkg/io/node/node.go # pkg/io/sigil/sigil.go # pkg/io/sigil/sigils.go
61
Taskfile.yml
|
|
@ -159,6 +159,67 @@ tasks:
|
||||||
cmds:
|
cmds:
|
||||||
- go run ./internal/tools/i18n-validate ./...
|
- 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
|
||||||
|
|
||||||
# --- Multi-repo (when in workspace) ---
|
# --- Multi-repo (when in workspace) ---
|
||||||
dev:health:
|
dev:health:
|
||||||
desc: "Check health of all repos"
|
desc: "Check health of all repos"
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,9 @@ export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: 'onboarding',
|
path: 'onboarding',
|
||||||
loadComponent: () => import('./onboarding/onboarding.component').then(m => m.OnboardingComponent)
|
loadComponent: () => import('./onboarding/onboarding.component').then(m => m.OnboardingComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'jellyfin',
|
||||||
|
loadComponent: () => import('./jellyfin/jellyfin.component').then(m => m.JellyfinComponent)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
187
cmd/bugseti/frontend/src/app/jellyfin/jellyfin.component.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
type Mode = 'web' | 'stream';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-jellyfin',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="jellyfin">
|
||||||
|
<header class="jellyfin__header">
|
||||||
|
<div>
|
||||||
|
<h1>Jellyfin Player</h1>
|
||||||
|
<p class="text-muted">Quick embed for media.lthn.ai or any Jellyfin host.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mode-switch">
|
||||||
|
<button class="btn btn--secondary" [class.is-active]="mode === 'web'" (click)="mode = 'web'">Web</button>
|
||||||
|
<button class="btn btn--secondary" [class.is-active]="mode === 'stream'" (click)="mode = 'stream'">Stream</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card jellyfin__config">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Jellyfin Server URL</label>
|
||||||
|
<input class="form-input" [(ngModel)]="serverUrl" placeholder="https://media.lthn.ai" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="mode === 'stream'" class="stream-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Item ID</label>
|
||||||
|
<input class="form-input" [(ngModel)]="itemId" placeholder="Jellyfin library item ID" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">API Key</label>
|
||||||
|
<input class="form-input" [(ngModel)]="apiKey" placeholder="Jellyfin API key" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Media Source ID (optional)</label>
|
||||||
|
<input class="form-input" [(ngModel)]="mediaSourceId" placeholder="Source ID for multi-source items" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn--primary" (click)="load()">Load Player</button>
|
||||||
|
<button class="btn btn--secondary" (click)="reset()">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card jellyfin__viewer" *ngIf="loaded && mode === 'web'">
|
||||||
|
<iframe
|
||||||
|
class="jellyfin-frame"
|
||||||
|
title="Jellyfin Web"
|
||||||
|
[src]="safeWebUrl"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card jellyfin__viewer" *ngIf="loaded && mode === 'stream'">
|
||||||
|
<video class="jellyfin-video" controls [src]="streamUrl"></video>
|
||||||
|
<p class="text-muted stream-hint" *ngIf="!streamUrl">Set Item ID and API key to build stream URL.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.jellyfin {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfin__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfin__header h1 {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch .btn.is-active {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfin__config {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfin__viewer {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 420px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfin-frame,
|
||||||
|
.jellyfin-video {
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 420px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-hint {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class JellyfinComponent {
|
||||||
|
mode: Mode = 'web';
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
serverUrl = 'https://media.lthn.ai';
|
||||||
|
itemId = '';
|
||||||
|
apiKey = '';
|
||||||
|
mediaSourceId = '';
|
||||||
|
|
||||||
|
safeWebUrl: SafeResourceUrl = this.sanitizer.bypassSecurityTrustResourceUrl('https://media.lthn.ai/web/index.html');
|
||||||
|
streamUrl = '';
|
||||||
|
|
||||||
|
constructor(private sanitizer: DomSanitizer) {}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
const base = this.normalizeBase(this.serverUrl);
|
||||||
|
this.safeWebUrl = this.sanitizer.bypassSecurityTrustResourceUrl(`${base}/web/index.html`);
|
||||||
|
this.streamUrl = this.buildStreamUrl(base);
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.loaded = false;
|
||||||
|
this.itemId = '';
|
||||||
|
this.apiKey = '';
|
||||||
|
this.mediaSourceId = '';
|
||||||
|
this.streamUrl = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeBase(value: string): string {
|
||||||
|
const raw = value.trim() || 'https://media.lthn.ai';
|
||||||
|
const withProtocol = raw.startsWith('http://') || raw.startsWith('https://') ? raw : `https://${raw}`;
|
||||||
|
return withProtocol.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStreamUrl(base: string): string {
|
||||||
|
if (!this.itemId.trim() || !this.apiKey.trim()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`${base}/Videos/${encodeURIComponent(this.itemId.trim())}/stream`);
|
||||||
|
url.searchParams.set('api_key', this.apiKey.trim());
|
||||||
|
url.searchParams.set('static', 'true');
|
||||||
|
if (this.mediaSourceId.trim()) {
|
||||||
|
url.searchParams.set('MediaSourceId', this.mediaSourceId.trim());
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,9 @@ interface TrayStatus {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="tray-footer">
|
<footer class="tray-footer">
|
||||||
|
<button class="btn btn--secondary btn--sm" (click)="openJellyfin()">
|
||||||
|
Jellyfin
|
||||||
|
</button>
|
||||||
<button class="btn btn--secondary btn--sm" (click)="toggleRunning()">
|
<button class="btn btn--secondary btn--sm" (click)="toggleRunning()">
|
||||||
{{ status.running ? 'Pause' : 'Start' }}
|
{{ status.running ? 'Pause' : 'Start' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -293,4 +296,8 @@ export class TrayComponent implements OnInit, OnDestroy {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openJellyfin() {
|
||||||
|
window.location.assign('/jellyfin');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
602
cmd/community/index.html
Normal file
|
|
@ -0,0 +1,602 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="scroll-smooth">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Lethean Community — Build Trust Through Code</title>
|
||||||
|
<meta name="description" content="An open source community where developers earn functional trust by fixing real bugs. BugSETI by Lethean.io — SETI@home for code.">
|
||||||
|
<link rel="canonical" href="https://lthn.community">
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="Lethean Community — Build Trust Through Code">
|
||||||
|
<meta property="og:description" content="An open source community where developers earn functional trust by fixing real bugs.">
|
||||||
|
<meta property="og:url" content="https://lthn.community">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
|
||||||
|
<!-- Tailwind CDN -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
lethean: {
|
||||||
|
950: '#070a0f',
|
||||||
|
900: '#0d1117',
|
||||||
|
800: '#161b22',
|
||||||
|
700: '#21262d',
|
||||||
|
600: '#30363d',
|
||||||
|
500: '#484f58',
|
||||||
|
400: '#8b949e',
|
||||||
|
300: '#c9d1d9',
|
||||||
|
200: '#e6edf3',
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
400: '#40c1c5',
|
||||||
|
500: '#2da8ac',
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
400: '#58a6ff',
|
||||||
|
500: '#4A90E2',
|
||||||
|
600: '#357ABD',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
display: ['"DM Sans"', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,300;1,9..40,400&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
background: #070a0f;
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grain overlay */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.03;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor glow */
|
||||||
|
.glow-cursor {
|
||||||
|
position: fixed;
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
background: radial-gradient(circle, rgba(64,193,197,0.06) 0%, transparent 70%);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing cursor blink */
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.cursor-blink::after {
|
||||||
|
content: '▊';
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
color: #40c1c5;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade-in on scroll */
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.fade-up {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeUp 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
.fade-up-d1 { animation-delay: 0.1s; }
|
||||||
|
.fade-up-d2 { animation-delay: 0.2s; }
|
||||||
|
.fade-up-d3 { animation-delay: 0.3s; }
|
||||||
|
.fade-up-d4 { animation-delay: 0.4s; }
|
||||||
|
.fade-up-d5 { animation-delay: 0.5s; }
|
||||||
|
.fade-up-d6 { animation-delay: 0.6s; }
|
||||||
|
|
||||||
|
/* Terminal-style section divider */
|
||||||
|
.terminal-line::before {
|
||||||
|
content: '$ ';
|
||||||
|
color: #40c1c5;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient border effect */
|
||||||
|
.gradient-border {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.gradient-border::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg, #40c1c5, #4A90E2, #40c1c5);
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Soft glow for hero text */
|
||||||
|
.text-glow {
|
||||||
|
text-shadow: 0 0 80px rgba(64,193,197,0.3), 0 0 32px rgba(64,193,197,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats counter animation */
|
||||||
|
@keyframes countUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link hover effect */
|
||||||
|
.link-underline {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.link-underline::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: #40c1c5;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.link-underline:hover::after {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="antialiased relative overflow-x-hidden">
|
||||||
|
|
||||||
|
<!-- Cursor glow follower -->
|
||||||
|
<div class="glow-cursor hidden lg:block" id="glowCursor"></div>
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<!-- NAV -->
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<nav class="fixed top-0 inset-x-0 z-40 backdrop-blur-xl bg-lethean-950/80 border-b border-lethean-600/30">
|
||||||
|
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
|
||||||
|
<a href="/" class="flex items-center gap-2.5 group">
|
||||||
|
<span class="text-cyan-400 font-mono text-sm font-medium tracking-tight">lthn</span>
|
||||||
|
<span class="text-lethean-500 font-mono text-xs">/</span>
|
||||||
|
<span class="text-lethean-300 text-sm font-medium">community</span>
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-6 text-sm">
|
||||||
|
<a href="#how-it-works" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">How it works</a>
|
||||||
|
<a href="#ecosystem" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">Ecosystem</a>
|
||||||
|
<a href="https://github.com/host-uk/core" 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://github.com/host-uk/core" target="_blank" rel="noopener" class="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-lethean-600/50 text-lethean-300 font-medium text-sm hover:bg-lethean-800/50 hover:border-lethean-500/50 transition-all">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||||
|
View Source
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<!-- HOW IT WORKS -->
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<section id="how-it-works" class="relative py-32">
|
||||||
|
<div class="max-w-5xl mx-auto px-6">
|
||||||
|
|
||||||
|
<div class="text-center mb-20">
|
||||||
|
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">How it works</p>
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">From install to impact</h2>
|
||||||
|
<p class="text-lethean-400 max-w-xl mx-auto">BugSETI runs in your system tray. It finds issues, prepares context, and gets out of your way. You write code. The community remembers.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-6">
|
||||||
|
<!-- Step 1 -->
|
||||||
|
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">1</span>
|
||||||
|
<h3 class="text-lethean-200 font-semibold">Install & connect</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed mb-4">Download BugSETI, connect your GitHub account. That's your identity in the Lethean Community — one account, everywhere.</p>
|
||||||
|
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
|
||||||
|
<span class="text-cyan-400/70">$</span> gh auth login<br>
|
||||||
|
<span class="text-cyan-400/70">$</span> bugseti init
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 -->
|
||||||
|
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">2</span>
|
||||||
|
<h3 class="text-lethean-200 font-semibold">Pick an issue</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed mb-4">BugSETI scans OSS repos for beginner-friendly issues. AI prepares context — the relevant files, similar past fixes, project conventions.</p>
|
||||||
|
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
|
||||||
|
<span class="text-green-400/70">✓</span> 7 issues ready<br>
|
||||||
|
<span class="text-green-400/70">✓</span> Context seeded
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3 -->
|
||||||
|
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">3</span>
|
||||||
|
<h3 class="text-lethean-200 font-semibold">Fix & earn trust</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed mb-4">Submit your PR. Every merged fix, every review, every contribution — it all counts. Your track record becomes your reputation.</p>
|
||||||
|
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
|
||||||
|
<span class="text-green-400/70">✓</span> PR #247 merged<br>
|
||||||
|
<span class="text-cyan-400/70">↑</span> Trust updated
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<!-- WHAT YOU GET -->
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<section class="relative py-24">
|
||||||
|
<div class="max-w-5xl mx-auto px-6">
|
||||||
|
|
||||||
|
<!-- BugSETI features -->
|
||||||
|
<div class="grid lg:grid-cols-2 gap-16 items-center mb-32">
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">The app</p>
|
||||||
|
<h2 class="text-3xl font-bold text-lethean-200 mb-4">A workbench in your tray</h2>
|
||||||
|
<p class="text-lethean-400 leading-relaxed mb-6">BugSETI lives in your system tray on macOS, Linux, and Windows. It quietly fetches issues, seeds AI context, and presents a clean workbench when you're ready to code.</p>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-cyan-400 mt-0.5 font-mono text-xs">→</span>
|
||||||
|
<span class="text-lethean-300">Priority queue — issues ranked by your skills and interests</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-cyan-400 mt-0.5 font-mono text-xs">→</span>
|
||||||
|
<span class="text-lethean-300">AI context seeding — relevant files and patterns, ready to go</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-cyan-400 mt-0.5 font-mono text-xs">→</span>
|
||||||
|
<span class="text-lethean-300">One-click PR submission — fork, branch, commit, push</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-cyan-400 mt-0.5 font-mono text-xs">→</span>
|
||||||
|
<span class="text-lethean-300">Stats tracking — streaks, repos contributed, PRs merged</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gradient-border rounded-xl overflow-hidden">
|
||||||
|
<div class="bg-lethean-900 rounded-xl p-1">
|
||||||
|
<!-- Mock app UI -->
|
||||||
|
<div class="bg-lethean-800 rounded-lg overflow-hidden">
|
||||||
|
<div class="flex items-center gap-1.5 px-3 py-2 bg-lethean-900/80 border-b border-lethean-700/30">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-red-400/40"></span>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-yellow-400/40"></span>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-400/40"></span>
|
||||||
|
<span class="ml-2 text-[10px] font-mono text-lethean-500">BugSETI — Workbench</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<!-- Mock issue card -->
|
||||||
|
<div class="p-3 rounded-md bg-lethean-900/60 border border-lethean-700/20">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-mono text-cyan-400/80">lodash/lodash#5821</span>
|
||||||
|
<span class="text-[10px] px-2 py-0.5 rounded-full bg-green-400/10 text-green-400/80 border border-green-400/20">good first issue</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-lethean-300 mb-2">Fix _.merge not handling Symbol properties</p>
|
||||||
|
<div class="flex items-center gap-3 text-[10px] text-lethean-500">
|
||||||
|
<span>⭐ 58.2k</span>
|
||||||
|
<span>JavaScript</span>
|
||||||
|
<span>Context ready</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mock issue card 2 -->
|
||||||
|
<div class="p-3 rounded-md bg-lethean-900/30 border border-lethean-700/10">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-mono text-lethean-500">vuejs/core#9214</span>
|
||||||
|
<span class="text-[10px] px-2 py-0.5 rounded-full bg-blue-400/10 text-blue-400/70 border border-blue-400/15">bug</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-lethean-400 mb-2">Teleport target not updating on HMR</p>
|
||||||
|
<div class="flex items-center gap-3 text-[10px] text-lethean-500">
|
||||||
|
<span>⭐ 44.7k</span>
|
||||||
|
<span>TypeScript</span>
|
||||||
|
<span>Seeding...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Status bar -->
|
||||||
|
<div class="flex items-center justify-between pt-2 border-t border-lethean-700/20 text-[10px] font-mono text-lethean-500">
|
||||||
|
<span>7 issues queued</span>
|
||||||
|
<span class="text-cyan-400/60">♫ dapp.fm playing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- dapp.fm teaser -->
|
||||||
|
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<div class="order-2 lg:order-1">
|
||||||
|
<div class="gradient-border rounded-xl overflow-hidden">
|
||||||
|
<div class="bg-lethean-900 rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-cyan-400/20 to-blue-500/20 flex items-center justify-center text-cyan-400">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lethean-200 font-semibold">dapp.fm</p>
|
||||||
|
<p class="text-xs text-lethean-500">Built into BugSETI</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mini player mock -->
|
||||||
|
<div class="bg-lethean-800/60 rounded-lg p-4 border border-lethean-700/20">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-10 h-10 rounded-md bg-gradient-to-br from-purple-500/30 to-cyan-400/30 flex items-center justify-center text-xs text-lethean-400">♫</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs text-lethean-200 truncate">It Feels So Good (Amnesia Mix)</p>
|
||||||
|
<p class="text-[10px] text-lethean-500">The Conductor & The Cowboy</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] font-mono text-lethean-500">3:42</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1 bg-lethean-700/50 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full w-2/3 bg-gradient-to-r from-cyan-400/60 to-cyan-400/30 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-lethean-500 mt-3 font-mono">Zero-trust DRM · Artists keep 95–100% · ChaCha20-Poly1305</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="order-1 lg:order-2">
|
||||||
|
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Built in</p>
|
||||||
|
<h2 class="text-3xl font-bold text-lethean-200 mb-4">Music while you merge</h2>
|
||||||
|
<p class="text-lethean-400 leading-relaxed mb-6">dapp.fm is a free music player built into BugSETI. Zero-trust DRM where the password is the license. Artists keep almost everything. No middlemen, no platform fees.</p>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed">The player is a working implementation of the Lethean protocol RFCs — encrypted, decentralised, and yours. Code, listen, contribute.</p>
|
||||||
|
<a href="https://demo.dapp.fm" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 mt-4 text-sm text-cyan-400 hover:text-cyan-400/80 transition-colors link-underline">
|
||||||
|
Try the demo
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<!-- ECOSYSTEM -->
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<section id="ecosystem" class="relative py-32">
|
||||||
|
<div class="max-w-5xl mx-auto px-6">
|
||||||
|
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Ecosystem</p>
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">One identity, everywhere</h2>
|
||||||
|
<p class="text-lethean-400 max-w-xl mx-auto">Your GitHub is your Lethean identity. One name across Web2, Web3, Handshake DNS, blockchain — verified by what you've actually done.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<!-- Card: Lethean Protocol -->
|
||||||
|
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||||
|
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Protocol</div>
|
||||||
|
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">Lethean Network</h3>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed">Privacy-first blockchain. Consent-gated networking via the UEPS protocol. Data sovereignty cryptographically enforced.</p>
|
||||||
|
<a href="https://lt.hn" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">lt.hn →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Handshake DNS -->
|
||||||
|
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||||
|
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Identity</div>
|
||||||
|
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">lthn/ everywhere</h3>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed">Handshake TLD, .io, .ai, .community, .eth, .tron — one name that resolves across every namespace. Your DID, decentralised.</p>
|
||||||
|
<a href="https://hns.to" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">hns.to →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Open Source -->
|
||||||
|
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||||
|
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Foundation</div>
|
||||||
|
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">EUPL-1.2</h3>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed">Every line is open source under the European Union Public License. 23 languages, no jurisdiction loopholes. Code stays open, forever.</p>
|
||||||
|
<a href="https://host.uk.com/oss" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">host.uk.com/oss →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: AI Models -->
|
||||||
|
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||||
|
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Coming</div>
|
||||||
|
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">lthn.ai</h3>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed">Open source EUPL-1.2 models up to 70B parameters. High quality, embeddable transformers for the community.</p>
|
||||||
|
<span class="inline-flex items-center gap-1 mt-3 text-xs text-lethean-500">Coming soon</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: dapp.fm -->
|
||||||
|
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||||
|
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Music</div>
|
||||||
|
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">dapp.fm</h3>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed">All-in-one publishing platform. Zero-trust DRM. Artists keep 95–100%. Built on Borg encryption and LTHN rolling keys.</p>
|
||||||
|
<a href="https://demo.dapp.fm" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">demo.dapp.fm →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Host UK -->
|
||||||
|
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
|
||||||
|
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Services</div>
|
||||||
|
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">Host UK</h3>
|
||||||
|
<p class="text-sm text-lethean-400 leading-relaxed">Infrastructure and services brand of the Lethean Community. Privacy-first hosting, analytics, trust verification, notifications.</p>
|
||||||
|
<a href="https://host.uk.com" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">host.uk.com →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<!-- JOIN / DOWNLOAD -->
|
||||||
|
<!-- ─────────────────────────────────────────────── -->
|
||||||
|
<section id="join" class="relative py-32">
|
||||||
|
<!-- Subtle gradient bg -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-cyan-400/[0.02] to-transparent"></div>
|
||||||
|
|
||||||
|
<div class="relative max-w-3xl mx-auto px-6 text-center">
|
||||||
|
|
||||||
|
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Get started</p>
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">Join the community</h2>
|
||||||
|
<p class="text-lethean-400 max-w-lg mx-auto mb-10">Install BugSETI. Connect your GitHub. Start contributing. Every bug you fix makes open source better — and builds a trust record that's cryptographically yours.</p>
|
||||||
|
|
||||||
|
<!-- Download buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 mb-12">
|
||||||
|
<a href="https://github.com/host-uk/core/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://github.com/host-uk/core/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://github.com/host-uk/core/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://github.com/host-uk/core</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>
|
||||||
100
cmd/core-app/CODEX_PROMPT.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# 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.ai / 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.
|
||||||
37
cmd/core-app/Taskfile.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
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
|
||||||
48
cmd/core-app/app_service.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
52
cmd/core-app/embed.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
167
cmd/core-app/env.go
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
67
cmd/core-app/go.mod
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
module github.com/host-uk/core/cmd/core-app
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dunglas/frankenphp v1.5.0
|
||||||
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/ProtonMail/go-crypto v1.3.0 // 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/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/dolthub/maphash v0.1.0 // indirect
|
||||||
|
github.com/ebitengine/purego v0.9.1 // indirect
|
||||||
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
|
github.com/gammazero/deque v1.0.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/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // 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 v1.2.4 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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.21.1 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
github.com/prometheus/common v0.63.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // 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/wailsapp/go-webview2 v1.0.23 // indirect
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace github.com/host-uk/core => ../..
|
||||||
185
cmd/core-app/go.sum
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
|
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/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/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/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||||
|
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||||
|
github.com/dunglas/frankenphp v1.5.0 h1:mrkJNe2gxlqYijGSpYIVbbRYxjYw2bmgAeDFqwREEk4=
|
||||||
|
github.com/dunglas/frankenphp v1.5.0/go.mod h1:tU9EirkVR0EuIr69IT1XBjSE6YfQY88tZlgkAvLPdOw=
|
||||||
|
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/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34=
|
||||||
|
github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo=
|
||||||
|
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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
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/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 v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
|
||||||
|
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
|
||||||
|
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/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.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||||
|
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
||||||
|
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||||
|
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||||
|
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||||
|
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/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/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/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=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||||
|
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/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=
|
||||||
137
cmd/core-app/handler.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
cmd/core-app/icons/appicon.png
Normal file
|
After Width: | Height: | Size: 76 B |
24
cmd/core-app/icons/icons.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// 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
|
||||||
BIN
cmd/core-app/icons/tray-dark.png
Normal file
|
After Width: | Height: | Size: 76 B |
BIN
cmd/core-app/icons/tray-light.png
Normal file
|
After Width: | Height: | Size: 76 B |
BIN
cmd/core-app/icons/tray-template.png
Normal file
|
After Width: | Height: | Size: 76 B |
13
cmd/core-app/laravel/.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
APP_NAME="Core App"
|
||||||
|
APP_ENV=production
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
DB_DATABASE=/tmp/core-app/database.sqlite
|
||||||
|
|
||||||
|
CACHE_STORE=file
|
||||||
|
SESSION_DRIVER=file
|
||||||
|
LOG_CHANNEL=single
|
||||||
|
LOG_LEVEL=warning
|
||||||
5
cmd/core-app/laravel/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/vendor/
|
||||||
|
/node_modules/
|
||||||
|
/.env
|
||||||
|
/bootstrap/cache/*.php
|
||||||
|
/storage/*.key
|
||||||
27
cmd/core-app/laravel/app/Livewire/Counter.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
28
cmd/core-app/laravel/app/Providers/AppServiceProvider.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
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).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
cmd/core-app/laravel/artisan
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/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);
|
||||||
20
cmd/core-app/laravel/bootstrap/app.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?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',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
//
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
//
|
||||||
|
})
|
||||||
|
->create();
|
||||||
7
cmd/core-app/laravel/bootstrap/providers.php
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
App\Providers\AppServiceProvider::class,
|
||||||
|
];
|
||||||
29
cmd/core-app/laravel/composer.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "host-uk/core-app",
|
||||||
|
"description": "Embedded Laravel application for Core App desktop",
|
||||||
|
"license": "EUPL-1.2",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.4",
|
||||||
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/octane": "^2.0",
|
||||||
|
"livewire/livewire": "^4.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"scripts": {
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6149
cmd/core-app/laravel/composer.lock
generated
Normal file
19
cmd/core-app/laravel/config/app.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?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',
|
||||||
|
],
|
||||||
|
];
|
||||||
21
cmd/core-app/laravel/config/cache.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?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_'),
|
||||||
|
];
|
||||||
25
cmd/core-app/laravel/config/database.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?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,
|
||||||
|
],
|
||||||
|
];
|
||||||
25
cmd/core-app/laravel/config/logging.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?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],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
22
cmd/core-app/laravel/config/session.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?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,
|
||||||
|
];
|
||||||
10
cmd/core-app/laravel/config/view.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'paths' => [
|
||||||
|
resource_path('views'),
|
||||||
|
],
|
||||||
|
'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))),
|
||||||
|
];
|
||||||
BIN
cmd/core-app/laravel/database/database.sqlite
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
19
cmd/core-app/laravel/public/index.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?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());
|
||||||
107
cmd/core-app/laravel/resources/views/components/layout.blade.php
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<!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>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<div class="counter">
|
||||||
|
<div class="counter__display">{{ $count }}</div>
|
||||||
|
<div class="counter__controls">
|
||||||
|
<button wire:click="decrement" class="btn btn--secondary">−</button>
|
||||||
|
<button wire:click="increment" class="btn btn--primary">+</button>
|
||||||
|
</div>
|
||||||
|
<p class="counter__hint">Livewire {{ \Livewire\Livewire::VERSION }} · Server-rendered, no page reload</p>
|
||||||
|
</div>
|
||||||
40
cmd/core-app/laravel/resources/views/welcome.blade.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<x-layout>
|
||||||
|
<div class="card">
|
||||||
|
<h1><span class="accent">Core App</span></h1>
|
||||||
|
<p class="subtitle">Laravel {{ app()->version() }} running inside a native desktop window</p>
|
||||||
|
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-item__label">PHP</div>
|
||||||
|
<div class="info-item__value">{{ PHP_VERSION }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-item__label">Thread Safety</div>
|
||||||
|
<div class="info-item__value">{{ PHP_ZTS ? 'ZTS (Yes)' : 'NTS (No)' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-item__label">SAPI</div>
|
||||||
|
<div class="info-item__value">{{ php_sapi_name() }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-item__label">Platform</div>
|
||||||
|
<div class="info-item__value">{{ PHP_OS }} {{ php_uname('m') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-item__label">Database</div>
|
||||||
|
<div class="info-item__value">SQLite {{ \SQLite3::version()['versionString'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-item__label">Mode</div>
|
||||||
|
<div class="info-item__value">{{ env('FRANKENPHP_WORKER') ? 'Octane Worker' : 'Standard' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="badge">Single Binary · No Server · No Config</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Livewire Reactivity Test</h2>
|
||||||
|
<livewire:counter />
|
||||||
|
</div>
|
||||||
|
</x-layout>
|
||||||
9
cmd/core-app/laravel/routes/web.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get('/', function () {
|
||||||
|
return view('welcome');
|
||||||
|
});
|
||||||
102
cmd/core-app/main.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/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)
|
||||||
|
}
|
||||||
96
cmd/core-app/native_bridge.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
71
cmd/core-ide/build_service.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/mcp/ide"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildService provides build monitoring bindings for the frontend.
|
||||||
|
type BuildService struct {
|
||||||
|
ideSub *ide.Subsystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBuildService creates a new BuildService.
|
||||||
|
func NewBuildService(ideSub *ide.Subsystem) *BuildService {
|
||||||
|
return &BuildService{ideSub: ideSub}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceName returns the service name for Wails.
|
||||||
|
func (s *BuildService) ServiceName() string { return "BuildService" }
|
||||||
|
|
||||||
|
// ServiceStartup is called when the Wails application starts.
|
||||||
|
func (s *BuildService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||||
|
log.Println("BuildService started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceShutdown is called when the Wails application shuts down.
|
||||||
|
func (s *BuildService) ServiceShutdown() error {
|
||||||
|
log.Println("BuildService shutdown")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDTO is a build for the frontend.
|
||||||
|
type BuildDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Duration string `json:"duration,omitempty"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBuilds returns recent builds.
|
||||||
|
func (s *BuildService) GetBuilds(repo string) []BuildDTO {
|
||||||
|
bridge := s.ideSub.Bridge()
|
||||||
|
if bridge == nil {
|
||||||
|
return []BuildDTO{}
|
||||||
|
}
|
||||||
|
_ = bridge.Send(ide.BridgeMessage{
|
||||||
|
Type: "build_list",
|
||||||
|
Data: map[string]any{"repo": repo},
|
||||||
|
})
|
||||||
|
return []BuildDTO{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBuildLogs returns log output for a specific build.
|
||||||
|
func (s *BuildService) GetBuildLogs(buildID string) []string {
|
||||||
|
bridge := s.ideSub.Bridge()
|
||||||
|
if bridge == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
_ = bridge.Send(ide.BridgeMessage{
|
||||||
|
Type: "build_logs",
|
||||||
|
Data: map[string]any{"buildId": buildID},
|
||||||
|
})
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
135
cmd/core-ide/chat_service.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/mcp/ide"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatService provides chat bindings for the frontend.
|
||||||
|
type ChatService struct {
|
||||||
|
ideSub *ide.Subsystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatService creates a new ChatService.
|
||||||
|
func NewChatService(ideSub *ide.Subsystem) *ChatService {
|
||||||
|
return &ChatService{ideSub: ideSub}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceName returns the service name for Wails.
|
||||||
|
func (s *ChatService) ServiceName() string { return "ChatService" }
|
||||||
|
|
||||||
|
// ServiceStartup is called when the Wails application starts.
|
||||||
|
func (s *ChatService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||||
|
log.Println("ChatService started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceShutdown is called when the Wails application shuts down.
|
||||||
|
func (s *ChatService) ServiceShutdown() error {
|
||||||
|
log.Println("ChatService shutdown")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatMessageDTO is a message for the frontend.
|
||||||
|
type ChatMessageDTO struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionDTO is a session for the frontend.
|
||||||
|
type SessionDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanStepDTO is a plan step for the frontend.
|
||||||
|
type PlanStepDTO struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanDTO is a plan for the frontend.
|
||||||
|
type PlanDTO struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Steps []PlanStepDTO `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a message to an agent session via the bridge.
|
||||||
|
func (s *ChatService) SendMessage(sessionID string, message string) (bool, error) {
|
||||||
|
bridge := s.ideSub.Bridge()
|
||||||
|
if bridge == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
err := bridge.Send(ide.BridgeMessage{
|
||||||
|
Type: "chat_send",
|
||||||
|
Channel: "chat:" + sessionID,
|
||||||
|
SessionID: sessionID,
|
||||||
|
Data: message,
|
||||||
|
})
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHistory retrieves message history for a session.
|
||||||
|
func (s *ChatService) GetHistory(sessionID string) []ChatMessageDTO {
|
||||||
|
bridge := s.ideSub.Bridge()
|
||||||
|
if bridge == nil {
|
||||||
|
return []ChatMessageDTO{}
|
||||||
|
}
|
||||||
|
_ = bridge.Send(ide.BridgeMessage{
|
||||||
|
Type: "chat_history",
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
|
return []ChatMessageDTO{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions returns active agent sessions.
|
||||||
|
func (s *ChatService) ListSessions() []SessionDTO {
|
||||||
|
bridge := s.ideSub.Bridge()
|
||||||
|
if bridge == nil {
|
||||||
|
return []SessionDTO{}
|
||||||
|
}
|
||||||
|
_ = bridge.Send(ide.BridgeMessage{Type: "session_list"})
|
||||||
|
return []SessionDTO{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession creates a new agent session.
|
||||||
|
func (s *ChatService) CreateSession(name string) SessionDTO {
|
||||||
|
bridge := s.ideSub.Bridge()
|
||||||
|
if bridge == nil {
|
||||||
|
return SessionDTO{Name: name, Status: "offline"}
|
||||||
|
}
|
||||||
|
_ = bridge.Send(ide.BridgeMessage{
|
||||||
|
Type: "session_create",
|
||||||
|
Data: map[string]any{"name": name},
|
||||||
|
})
|
||||||
|
return SessionDTO{
|
||||||
|
Name: name,
|
||||||
|
Status: "creating",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlanStatus returns the plan status for a session.
|
||||||
|
func (s *ChatService) GetPlanStatus(sessionID string) PlanDTO {
|
||||||
|
bridge := s.ideSub.Bridge()
|
||||||
|
if bridge == nil {
|
||||||
|
return PlanDTO{SessionID: sessionID, Status: "offline"}
|
||||||
|
}
|
||||||
|
_ = bridge.Send(ide.BridgeMessage{
|
||||||
|
Type: "plan_status",
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
|
return PlanDTO{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: "unknown",
|
||||||
|
Steps: []PlanStepDTO{},
|
||||||
|
}
|
||||||
|
}
|
||||||
91
cmd/core-ide/frontend/angular.json
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"core-ide": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss",
|
||||||
|
"standalone": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/core-ide",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kb",
|
||||||
|
"maximumError": "1mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "2kb",
|
||||||
|
"maximumError": "4kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "core-ide:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "core-ide:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": ["zone.js", "zone.js/testing"],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
cmd/core-ide/frontend/package.json
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"name": "core-ide",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"dev": "ng serve --configuration development",
|
||||||
|
"build": "ng build --configuration production",
|
||||||
|
"build:dev": "ng build --configuration development",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"lint": "ng lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^19.1.0",
|
||||||
|
"@angular/common": "^19.1.0",
|
||||||
|
"@angular/compiler": "^19.1.0",
|
||||||
|
"@angular/core": "^19.1.0",
|
||||||
|
"@angular/forms": "^19.1.0",
|
||||||
|
"@angular/platform-browser": "^19.1.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^19.1.0",
|
||||||
|
"@angular/router": "^19.1.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^19.1.0",
|
||||||
|
"@angular/cli": "^21.1.2",
|
||||||
|
"@angular/compiler-cli": "^19.1.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"jasmine-core": "~5.1.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.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
cmd/core-ide/frontend/src/app/app.component.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet],
|
||||||
|
template: '<router-outlet></router-outlet>',
|
||||||
|
styles: [`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class AppComponent {
|
||||||
|
title = 'Core IDE';
|
||||||
|
}
|
||||||
9
cmd/core-ide/frontend/src/app/app.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { ApplicationConfig } from '@angular/core';
|
||||||
|
import { provideRouter, withHashLocation } from '@angular/router';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideRouter(routes, withHashLocation())
|
||||||
|
]
|
||||||
|
};
|
||||||
25
cmd/core-ide/frontend/src/app/app.routes.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirectTo: 'tray',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tray',
|
||||||
|
loadComponent: () => import('./tray/tray.component').then(m => m.TrayComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'main',
|
||||||
|
loadComponent: () => import('./main/main.component').then(m => m.MainComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'jellyfin',
|
||||||
|
loadComponent: () => import('./jellyfin/jellyfin.component').then(m => m.JellyfinComponent)
|
||||||
|
}
|
||||||
|
];
|
||||||
184
cmd/core-ide/frontend/src/app/build/build.component.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { WailsService, Build } from '@shared/wails.service';
|
||||||
|
import { WebSocketService, WSMessage } from '@shared/ws.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-build',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="builds">
|
||||||
|
<div class="builds__header">
|
||||||
|
<h2>Builds</h2>
|
||||||
|
<button class="btn btn--secondary" (click)="refresh()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="builds__list">
|
||||||
|
<div
|
||||||
|
*ngFor="let build of builds; trackBy: trackBuild"
|
||||||
|
class="build-card"
|
||||||
|
[class.build-card--expanded]="expandedId === build.id"
|
||||||
|
(click)="toggle(build.id)"
|
||||||
|
>
|
||||||
|
<div class="build-card__header">
|
||||||
|
<div class="build-card__info">
|
||||||
|
<span class="build-card__repo">{{ build.repo }}</span>
|
||||||
|
<span class="build-card__branch text-muted">{{ build.branch }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge" [class]="statusBadge(build.status)">{{ build.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="build-card__meta text-muted">
|
||||||
|
{{ build.startedAt | date:'medium' }}
|
||||||
|
<span *ngIf="build.duration"> · {{ build.duration }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="expandedId === build.id" class="build-card__logs">
|
||||||
|
<pre *ngIf="logs.length > 0">{{ logs.join('\\n') }}</pre>
|
||||||
|
<p *ngIf="logs.length === 0" class="text-muted">No logs available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="builds.length === 0" class="builds__empty text-muted">
|
||||||
|
No builds found. Builds will appear here from Forgejo CI.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.builds {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.builds__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.builds__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-card__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-card__info {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-card__repo {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-card__branch {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-card__meta {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-card__logs {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-card__logs pre {
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builds__empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class BuildComponent implements OnInit, OnDestroy {
|
||||||
|
builds: Build[] = [];
|
||||||
|
expandedId = '';
|
||||||
|
logs: string[] = [];
|
||||||
|
|
||||||
|
private sub: Subscription | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private wails: WailsService,
|
||||||
|
private wsService: WebSocketService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.refresh();
|
||||||
|
this.wsService.connect();
|
||||||
|
this.sub = this.wsService.subscribe('build:status').subscribe(
|
||||||
|
(msg: WSMessage) => {
|
||||||
|
if (msg.data && typeof msg.data === 'object') {
|
||||||
|
const update = msg.data as Build;
|
||||||
|
const idx = this.builds.findIndex(b => b.id === update.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.builds[idx] = { ...this.builds[idx], ...update };
|
||||||
|
} else {
|
||||||
|
this.builds.unshift(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.sub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
this.builds = await this.wails.getBuilds();
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggle(buildId: string): Promise<void> {
|
||||||
|
if (this.expandedId === buildId) {
|
||||||
|
this.expandedId = '';
|
||||||
|
this.logs = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.expandedId = buildId;
|
||||||
|
this.logs = await this.wails.getBuildLogs(buildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackBuild(_: number, build: Build): string {
|
||||||
|
return build.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusBadge(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'success': return 'badge--success';
|
||||||
|
case 'running': return 'badge--info';
|
||||||
|
case 'failed': return 'badge--danger';
|
||||||
|
default: return 'badge--warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
cmd/core-ide/frontend/src/app/chat/chat.component.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { WailsService, ChatMessage, Session, PlanStatus } from '@shared/wails.service';
|
||||||
|
import { WebSocketService, WSMessage } from '@shared/ws.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chat',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="chat">
|
||||||
|
<div class="chat__header">
|
||||||
|
<div class="chat__session-picker">
|
||||||
|
<select class="form-select" [(ngModel)]="activeSessionId" (ngModelChange)="onSessionChange()">
|
||||||
|
<option *ngFor="let s of sessions" [value]="s.id">{{ s.name }} ({{ s.status }})</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn--ghost" (click)="createSession()">+ New</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat__body">
|
||||||
|
<div class="chat__messages">
|
||||||
|
<div
|
||||||
|
*ngFor="let msg of messages"
|
||||||
|
class="chat__msg"
|
||||||
|
[class.chat__msg--user]="msg.role === 'user'"
|
||||||
|
[class.chat__msg--agent]="msg.role === 'agent'"
|
||||||
|
>
|
||||||
|
<div class="chat__msg-role">{{ msg.role }}</div>
|
||||||
|
<div class="chat__msg-content">{{ msg.content }}</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="messages.length === 0" class="chat__empty text-muted">
|
||||||
|
No messages yet. Start a conversation with an agent.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="plan.steps.length > 0" class="chat__plan">
|
||||||
|
<h4>Plan: {{ plan.status }}</h4>
|
||||||
|
<ul>
|
||||||
|
<li *ngFor="let step of plan.steps" [class]="'plan-step plan-step--' + step.status">
|
||||||
|
{{ step.name }}
|
||||||
|
<span class="badge badge--info">{{ step.status }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat__input">
|
||||||
|
<textarea
|
||||||
|
class="form-textarea"
|
||||||
|
[(ngModel)]="draft"
|
||||||
|
(keydown.enter)="sendMessage($event)"
|
||||||
|
placeholder="Type a message... (Enter to send)"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<button class="btn btn--primary" (click)="sendMessage()" [disabled]="!draft.trim()">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__header {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__session-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__session-picker select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__msg {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__msg--user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: rgba(57, 208, 216, 0.12);
|
||||||
|
border: 1px solid rgba(57, 208, 216, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__msg--agent {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__msg-role {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__msg-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__empty {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__plan {
|
||||||
|
width: 260px;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__plan ul {
|
||||||
|
list-style: none;
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__plan li {
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__input {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__input textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ChatComponent implements OnInit, OnDestroy {
|
||||||
|
sessions: Session[] = [];
|
||||||
|
activeSessionId = '';
|
||||||
|
messages: ChatMessage[] = [];
|
||||||
|
plan: PlanStatus = { sessionId: '', status: '', steps: [] };
|
||||||
|
draft = '';
|
||||||
|
|
||||||
|
private sub: Subscription | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private wails: WailsService,
|
||||||
|
private wsService: WebSocketService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadSessions();
|
||||||
|
this.wsService.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.sub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSessions(): Promise<void> {
|
||||||
|
this.sessions = await this.wails.listSessions();
|
||||||
|
if (this.sessions.length > 0 && !this.activeSessionId) {
|
||||||
|
this.activeSessionId = this.sessions[0].id;
|
||||||
|
this.onSessionChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSessionChange(): Promise<void> {
|
||||||
|
if (!this.activeSessionId) return;
|
||||||
|
|
||||||
|
// Unsubscribe from previous channel
|
||||||
|
this.sub?.unsubscribe();
|
||||||
|
|
||||||
|
// Load history and plan
|
||||||
|
this.messages = await this.wails.getHistory(this.activeSessionId);
|
||||||
|
this.plan = await this.wails.getPlanStatus(this.activeSessionId);
|
||||||
|
|
||||||
|
// Subscribe to live updates
|
||||||
|
this.sub = this.wsService.subscribe(`chat:${this.activeSessionId}`).subscribe(
|
||||||
|
(msg: WSMessage) => {
|
||||||
|
if (msg.data && typeof msg.data === 'object') {
|
||||||
|
this.messages.push(msg.data as ChatMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(event?: KeyboardEvent): Promise<void> {
|
||||||
|
if (event) {
|
||||||
|
if (event.shiftKey) return; // Allow shift+enter for newlines
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
const text = this.draft.trim();
|
||||||
|
if (!text || !this.activeSessionId) return;
|
||||||
|
|
||||||
|
// Optimistic UI update
|
||||||
|
this.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
|
||||||
|
this.draft = '';
|
||||||
|
|
||||||
|
await this.wails.sendMessage(this.activeSessionId, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(): Promise<void> {
|
||||||
|
const name = `Session ${this.sessions.length + 1}`;
|
||||||
|
const session = await this.wails.createSession(name);
|
||||||
|
this.sessions.push(session);
|
||||||
|
this.activeSessionId = session.id;
|
||||||
|
this.onSessionChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
163
cmd/core-ide/frontend/src/app/dashboard/dashboard.component.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { WailsService, DashboardData } from '@shared/wails.service';
|
||||||
|
import { WebSocketService, WSMessage } from '@shared/ws.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="dashboard">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
|
||||||
|
<div class="dashboard__grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card__value" [class.text-success]="data.connection.bridgeConnected">
|
||||||
|
{{ data.connection.bridgeConnected ? 'Online' : 'Offline' }}
|
||||||
|
</div>
|
||||||
|
<div class="stat-card__label">Bridge Status</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card__value">{{ data.connection.wsClients }}</div>
|
||||||
|
<div class="stat-card__label">WS Clients</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card__value">{{ data.connection.wsChannels }}</div>
|
||||||
|
<div class="stat-card__label">Active Channels</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card__value">0</div>
|
||||||
|
<div class="stat-card__label">Agent Sessions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard__activity">
|
||||||
|
<h3>Activity Feed</h3>
|
||||||
|
<div class="activity-feed">
|
||||||
|
<div *ngFor="let item of activity" class="activity-item">
|
||||||
|
<span class="activity-item__badge badge badge--info">{{ item.type }}</span>
|
||||||
|
<span class="activity-item__msg">{{ item.message }}</span>
|
||||||
|
<span class="activity-item__time text-muted">{{ item.timestamp | date:'shortTime' }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="activity.length === 0" class="text-muted" style="text-align: center; padding: var(--spacing-lg);">
|
||||||
|
No recent activity. Events will stream here in real-time.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.dashboard {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin: var(--spacing-md) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card__value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__activity {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-feed {
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item__msg {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item__time {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class DashboardComponent implements OnInit, OnDestroy {
|
||||||
|
data: DashboardData = {
|
||||||
|
connection: { bridgeConnected: false, laravelUrl: '', wsClients: 0, wsChannels: 0 }
|
||||||
|
};
|
||||||
|
activity: ActivityItem[] = [];
|
||||||
|
|
||||||
|
private sub: Subscription | null = null;
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private wails: WailsService,
|
||||||
|
private wsService: WebSocketService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.refresh();
|
||||||
|
this.pollTimer = setInterval(() => this.refresh(), 10000);
|
||||||
|
|
||||||
|
this.wsService.connect();
|
||||||
|
this.sub = this.wsService.subscribe('dashboard:activity').subscribe(
|
||||||
|
(msg: WSMessage) => {
|
||||||
|
if (msg.data && typeof msg.data === 'object') {
|
||||||
|
this.activity.unshift(msg.data as ActivityItem);
|
||||||
|
if (this.activity.length > 100) {
|
||||||
|
this.activity.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.sub?.unsubscribe();
|
||||||
|
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
this.data = await this.wails.getDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
175
cmd/core-ide/frontend/src/app/jellyfin/jellyfin.component.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
type Mode = 'web' | 'stream';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-jellyfin',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="jellyfin">
|
||||||
|
<header class="jellyfin__header">
|
||||||
|
<div>
|
||||||
|
<h2>Jellyfin Player</h2>
|
||||||
|
<p class="text-muted">Embedded media access for Host UK workflows.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mode-switch">
|
||||||
|
<button class="btn btn--secondary" [class.is-active]="mode === 'web'" (click)="mode = 'web'">Web</button>
|
||||||
|
<button class="btn btn--secondary" [class.is-active]="mode === 'stream'" (click)="mode = 'stream'">Stream</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Jellyfin Server URL</label>
|
||||||
|
<input class="form-input" [(ngModel)]="serverUrl" placeholder="https://media.lthn.ai" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="mode === 'stream'" class="stream-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Item ID</label>
|
||||||
|
<input class="form-input" [(ngModel)]="itemId" placeholder="Jellyfin library item ID" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">API Key</label>
|
||||||
|
<input class="form-input" [(ngModel)]="apiKey" placeholder="Jellyfin API key" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Media Source ID (optional)</label>
|
||||||
|
<input class="form-input" [(ngModel)]="mediaSourceId" placeholder="Source ID for multi-source items" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn--primary" (click)="load()">Load Player</button>
|
||||||
|
<button class="btn btn--secondary" (click)="reset()">Reset</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card viewer" *ngIf="loaded && mode === 'web'">
|
||||||
|
<iframe
|
||||||
|
class="jellyfin-frame"
|
||||||
|
title="Jellyfin Web"
|
||||||
|
[src]="safeWebUrl"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
></iframe>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card viewer" *ngIf="loaded && mode === 'stream'">
|
||||||
|
<video class="jellyfin-video" controls [src]="streamUrl"></video>
|
||||||
|
<p class="text-muted stream-hint" *ngIf="!streamUrl">Set Item ID and API key to build stream URL.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.jellyfin {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfin__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch .btn.is-active {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfin-frame,
|
||||||
|
.jellyfin-video {
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 520px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-hint {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class JellyfinComponent {
|
||||||
|
mode: Mode = 'web';
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
serverUrl = 'https://media.lthn.ai';
|
||||||
|
itemId = '';
|
||||||
|
apiKey = '';
|
||||||
|
mediaSourceId = '';
|
||||||
|
|
||||||
|
safeWebUrl: SafeResourceUrl = this.sanitizer.bypassSecurityTrustResourceUrl('https://media.lthn.ai/web/index.html');
|
||||||
|
streamUrl = '';
|
||||||
|
|
||||||
|
constructor(private sanitizer: DomSanitizer) {}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
const base = this.normalizeBase(this.serverUrl);
|
||||||
|
this.safeWebUrl = this.sanitizer.bypassSecurityTrustResourceUrl(`${base}/web/index.html`);
|
||||||
|
this.streamUrl = this.buildStreamUrl(base);
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.loaded = false;
|
||||||
|
this.itemId = '';
|
||||||
|
this.apiKey = '';
|
||||||
|
this.mediaSourceId = '';
|
||||||
|
this.streamUrl = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeBase(value: string): string {
|
||||||
|
const raw = value.trim() || 'https://media.lthn.ai';
|
||||||
|
const withProtocol = raw.startsWith('http://') || raw.startsWith('https://') ? raw : `https://${raw}`;
|
||||||
|
return withProtocol.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStreamUrl(base: string): string {
|
||||||
|
if (!this.itemId.trim() || !this.apiKey.trim()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`${base}/Videos/${encodeURIComponent(this.itemId.trim())}/stream`);
|
||||||
|
url.searchParams.set('api_key', this.apiKey.trim());
|
||||||
|
url.searchParams.set('static', 'true');
|
||||||
|
if (this.mediaSourceId.trim()) {
|
||||||
|
url.searchParams.set('MediaSourceId', this.mediaSourceId.trim());
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
cmd/core-ide/frontend/src/app/main/main.component.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { ChatComponent } from '../chat/chat.component';
|
||||||
|
import { BuildComponent } from '../build/build.component';
|
||||||
|
import { DashboardComponent } from '../dashboard/dashboard.component';
|
||||||
|
import { JellyfinComponent } from '../jellyfin/jellyfin.component';
|
||||||
|
|
||||||
|
type Panel = 'chat' | 'build' | 'dashboard' | 'jellyfin';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-main',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet, ChatComponent, BuildComponent, DashboardComponent, JellyfinComponent],
|
||||||
|
template: `
|
||||||
|
<div class="ide">
|
||||||
|
<nav class="ide__sidebar">
|
||||||
|
<div class="ide__logo">Core IDE</div>
|
||||||
|
<ul class="ide__nav">
|
||||||
|
<li
|
||||||
|
*ngFor="let item of navItems"
|
||||||
|
class="ide__nav-item"
|
||||||
|
[class.active]="activePanel === item.id"
|
||||||
|
(click)="activePanel = item.id"
|
||||||
|
>
|
||||||
|
<span class="ide__nav-icon">{{ item.icon }}</span>
|
||||||
|
<span class="ide__nav-label">{{ item.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="ide__nav-footer text-muted">v0.1.0</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="ide__content">
|
||||||
|
<app-chat *ngIf="activePanel === 'chat'" />
|
||||||
|
<app-build *ngIf="activePanel === 'build'" />
|
||||||
|
<app-dashboard *ngIf="activePanel === 'dashboard'" />
|
||||||
|
<app-jellyfin *ngIf="activePanel === 'jellyfin'" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.ide {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ide__sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--spacing-md) 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ide__logo {
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ide__nav {
|
||||||
|
list-style: none;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ide__nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
background: rgba(57, 208, 216, 0.08);
|
||||||
|
border-left-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ide__nav-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ide__nav-footer {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ide__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class MainComponent {
|
||||||
|
activePanel: Panel = 'dashboard';
|
||||||
|
|
||||||
|
navItems: { id: Panel; label: string; icon: string }[] = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: '\u25A6' },
|
||||||
|
{ id: 'chat', label: 'Chat', icon: '\u2709' },
|
||||||
|
{ id: 'build', label: 'Builds', icon: '\u2699' },
|
||||||
|
{ id: 'jellyfin', label: 'Jellyfin', icon: '\u25B6' },
|
||||||
|
];
|
||||||
|
}
|
||||||
105
cmd/core-ide/frontend/src/app/settings/settings.component.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="settings">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
|
||||||
|
<div class="settings__section">
|
||||||
|
<h3>Connection</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Laravel WebSocket URL</label>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
[(ngModel)]="laravelUrl"
|
||||||
|
placeholder="ws://localhost:9876/ws"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Workspace Root</label>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
[(ngModel)]="workspaceRoot"
|
||||||
|
placeholder="/path/to/workspace"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings__section">
|
||||||
|
<h3>Appearance</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Theme</label>
|
||||||
|
<select class="form-select" [(ngModel)]="theme">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings__actions">
|
||||||
|
<button class="btn btn--primary" (click)="save()">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.settings {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__section {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__actions {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class SettingsComponent implements OnInit {
|
||||||
|
laravelUrl = 'ws://localhost:9876/ws';
|
||||||
|
workspaceRoot = '.';
|
||||||
|
theme = 'dark';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Settings will be loaded from the Go backend
|
||||||
|
const saved = localStorage.getItem('ide-settings');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
this.laravelUrl = parsed.laravelUrl ?? this.laravelUrl;
|
||||||
|
this.workspaceRoot = parsed.workspaceRoot ?? this.workspaceRoot;
|
||||||
|
this.theme = parsed.theme ?? this.theme;
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
localStorage.setItem('ide-settings', JSON.stringify({
|
||||||
|
laravelUrl: this.laravelUrl,
|
||||||
|
workspaceRoot: this.workspaceRoot,
|
||||||
|
theme: this.theme,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.theme === 'light') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
cmd/core-ide/frontend/src/app/shared/wails.service.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
// Type-safe wrapper for Wails v3 Go service bindings.
|
||||||
|
// At runtime, `window.go.main.{ServiceName}.{Method}()` returns a Promise.
|
||||||
|
|
||||||
|
interface WailsGo {
|
||||||
|
main: {
|
||||||
|
IDEService: {
|
||||||
|
GetConnectionStatus(): Promise<ConnectionStatus>;
|
||||||
|
GetDashboard(): Promise<DashboardData>;
|
||||||
|
ShowWindow(name: string): Promise<void>;
|
||||||
|
};
|
||||||
|
ChatService: {
|
||||||
|
SendMessage(sessionId: string, message: string): Promise<boolean>;
|
||||||
|
GetHistory(sessionId: string): Promise<ChatMessage[]>;
|
||||||
|
ListSessions(): Promise<Session[]>;
|
||||||
|
CreateSession(name: string): Promise<Session>;
|
||||||
|
GetPlanStatus(sessionId: string): Promise<PlanStatus>;
|
||||||
|
};
|
||||||
|
BuildService: {
|
||||||
|
GetBuilds(repo: string): Promise<Build[]>;
|
||||||
|
GetBuildLogs(buildId: string): Promise<string[]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionStatus {
|
||||||
|
bridgeConnected: boolean;
|
||||||
|
laravelUrl: string;
|
||||||
|
wsClients: number;
|
||||||
|
wsChannels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
connection: ConnectionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanStatus {
|
||||||
|
sessionId: string;
|
||||||
|
status: string;
|
||||||
|
steps: PlanStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanStep {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Build {
|
||||||
|
id: string;
|
||||||
|
repo: string;
|
||||||
|
branch: string;
|
||||||
|
status: string;
|
||||||
|
duration?: string;
|
||||||
|
startedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
go: WailsGo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class WailsService {
|
||||||
|
private get ide() { return window.go?.main?.IDEService; }
|
||||||
|
private get chat() { return window.go?.main?.ChatService; }
|
||||||
|
private get build() { return window.go?.main?.BuildService; }
|
||||||
|
|
||||||
|
// IDE
|
||||||
|
getConnectionStatus(): Promise<ConnectionStatus> {
|
||||||
|
return this.ide?.GetConnectionStatus() ?? Promise.resolve({
|
||||||
|
bridgeConnected: false, laravelUrl: '', wsClients: 0, wsChannels: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDashboard(): Promise<DashboardData> {
|
||||||
|
return this.ide?.GetDashboard() ?? Promise.resolve({
|
||||||
|
connection: { bridgeConnected: false, laravelUrl: '', wsClients: 0, wsChannels: 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showWindow(name: string): Promise<void> {
|
||||||
|
return this.ide?.ShowWindow(name) ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
sendMessage(sessionId: string, message: string): Promise<boolean> {
|
||||||
|
return this.chat?.SendMessage(sessionId, message) ?? Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHistory(sessionId: string): Promise<ChatMessage[]> {
|
||||||
|
return this.chat?.GetHistory(sessionId) ?? Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
listSessions(): Promise<Session[]> {
|
||||||
|
return this.chat?.ListSessions() ?? Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(name: string): Promise<Session> {
|
||||||
|
return this.chat?.CreateSession(name) ?? Promise.resolve({
|
||||||
|
id: '', name, status: 'offline', createdAt: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlanStatus(sessionId: string): Promise<PlanStatus> {
|
||||||
|
return this.chat?.GetPlanStatus(sessionId) ?? Promise.resolve({
|
||||||
|
sessionId, status: 'offline', steps: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build
|
||||||
|
getBuilds(repo: string = ''): Promise<Build[]> {
|
||||||
|
return this.build?.GetBuilds(repo) ?? Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBuildLogs(buildId: string): Promise<string[]> {
|
||||||
|
return this.build?.GetBuildLogs(buildId) ?? Promise.resolve([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
cmd/core-ide/frontend/src/app/shared/ws.service.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { Injectable, OnDestroy } from '@angular/core';
|
||||||
|
import { Subject, Observable } from 'rxjs';
|
||||||
|
import { filter } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export interface WSMessage {
|
||||||
|
type: string;
|
||||||
|
channel?: string;
|
||||||
|
processId?: string;
|
||||||
|
data?: unknown;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class WebSocketService implements OnDestroy {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private messages$ = new Subject<WSMessage>();
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private url = 'ws://127.0.0.1:9877/ws';
|
||||||
|
private connected = false;
|
||||||
|
|
||||||
|
connect(url?: string): void {
|
||||||
|
if (url) this.url = url;
|
||||||
|
this.doConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private doConnect(): void {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.url);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.connected = true;
|
||||||
|
console.log('[WS] Connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const msg: WSMessage = JSON.parse(event.data);
|
||||||
|
this.messages$.next(msg);
|
||||||
|
} catch {
|
||||||
|
console.warn('[WS] Failed to parse message');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.connected = false;
|
||||||
|
console.log('[WS] Disconnected, reconnecting in 3s...');
|
||||||
|
this.reconnectTimer = setTimeout(() => this.doConnect(), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.ws?.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(channel: string): Observable<WSMessage> {
|
||||||
|
// Send subscribe command to hub
|
||||||
|
this.send({ type: 'subscribe', data: channel, timestamp: new Date().toISOString() });
|
||||||
|
return this.messages$.pipe(
|
||||||
|
filter(msg => msg.channel === channel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(channel: string): void {
|
||||||
|
this.send({ type: 'unsubscribe', data: channel, timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
send(msg: WSMessage): void {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
get allMessages$(): Observable<WSMessage> {
|
||||||
|
return this.messages$.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||||
|
this.ws?.close();
|
||||||
|
this.messages$.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
124
cmd/core-ide/frontend/src/app/tray/tray.component.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { WailsService, ConnectionStatus } from '@shared/wails.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tray',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="tray">
|
||||||
|
<div class="tray__header">
|
||||||
|
<h3>Core IDE</h3>
|
||||||
|
<span class="badge" [class]="status.bridgeConnected ? 'badge--success' : 'badge--danger'">
|
||||||
|
{{ status.bridgeConnected ? 'Online' : 'Offline' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tray__stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat__value">{{ status.wsClients }}</span>
|
||||||
|
<span class="stat__label">WS Clients</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat__value">{{ status.wsChannels }}</span>
|
||||||
|
<span class="stat__label">Channels</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tray__actions">
|
||||||
|
<button class="btn btn--primary" (click)="openMain()">Open IDE</button>
|
||||||
|
<button class="btn btn--secondary" (click)="openSettings()">Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tray__footer text-muted">
|
||||||
|
Laravel bridge: {{ status.bridgeConnected ? 'connected' : 'disconnected' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.tray {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat__value {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray__actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray__footer {
|
||||||
|
margin-top: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class TrayComponent implements OnInit {
|
||||||
|
status: ConnectionStatus = {
|
||||||
|
bridgeConnected: false,
|
||||||
|
laravelUrl: '',
|
||||||
|
wsClients: 0,
|
||||||
|
wsChannels: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(private wails: WailsService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.refresh();
|
||||||
|
this.pollTimer = setInterval(() => this.refresh(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
this.status = await this.wails.getConnectionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
openMain(): void {
|
||||||
|
this.wails.showWindow('main');
|
||||||
|
}
|
||||||
|
|
||||||
|
openSettings(): void {
|
||||||
|
this.wails.showWindow('settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
cmd/core-ide/frontend/src/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Core IDE</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
cmd/core-ide/frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
247
cmd/core-ide/frontend/src/styles.scss
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
// Core IDE Global Styles
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// Dark theme (default) — IDE accent: teal/cyan
|
||||||
|
--bg-primary: #161b22;
|
||||||
|
--bg-secondary: #0d1117;
|
||||||
|
--bg-tertiary: #21262d;
|
||||||
|
--bg-sidebar: #131820;
|
||||||
|
--text-primary: #c9d1d9;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--text-muted: #6e7681;
|
||||||
|
--border-color: #30363d;
|
||||||
|
--accent-primary: #39d0d8;
|
||||||
|
--accent-secondary: #58a6ff;
|
||||||
|
--accent-success: #3fb950;
|
||||||
|
--accent-warning: #d29922;
|
||||||
|
--accent-danger: #f85149;
|
||||||
|
|
||||||
|
// Spacing
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 16px;
|
||||||
|
--spacing-lg: 24px;
|
||||||
|
--spacing-xl: 32px;
|
||||||
|
|
||||||
|
// Border radius
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
|
||||||
|
// Font
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||||
|
|
||||||
|
// IDE-specific
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--chat-input-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 24px; }
|
||||||
|
h2 { font-size: 20px; }
|
||||||
|
h3 { font-size: 16px; }
|
||||||
|
h4 { font-size: 14px; }
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code, pre {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollbar styling
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background-color: var(--accent-primary);
|
||||||
|
color: #0d1117;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--secondary {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background-color: var(--accent-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(57, 208, 216, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badges
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background-color: rgba(63, 185, 80, 0.15);
|
||||||
|
color: var(--accent-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background-color: rgba(210, 153, 34, 0.15);
|
||||||
|
color: var(--accent-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background-color: rgba(248, 81, 73, 0.15);
|
||||||
|
color: var(--accent-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
background-color: rgba(57, 208, 216, 0.15);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility classes
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-success { color: var(--accent-success); }
|
||||||
|
.text-danger { color: var(--accent-danger); }
|
||||||
|
.text-warning { color: var(--accent-warning); }
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
13
cmd/core-ide/frontend/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
35
cmd/core-ide/frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"dom"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@app/*": ["src/app/*"],
|
||||||
|
"@shared/*": ["src/app/shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
57
cmd/core-ide/go.mod
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
module github.com/host-uk/core/cmd/core-ide
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/host-uk/core v0.0.0
|
||||||
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
|
github.com/adrg/xdg v0.5.3 // indirect
|
||||||
|
github.com/bep/debounce v1.2.1 // 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/ebitengine/purego v0.9.1 // indirect
|
||||||
|
github.com/emirpasic/gods v1.18.1 // 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/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // 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/modelcontextprotocol/go-sdk v1.2.0 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // 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/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
|
||||||
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace github.com/host-uk/core => ../..
|
||||||
165
cmd/core-ide/go.sum
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
|
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/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/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
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/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/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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
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/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
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/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/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/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||||
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
|
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/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/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/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/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=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||||
|
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
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=
|
||||||
BIN
cmd/core-ide/icons/appicon.png
Normal file
|
After Width: | Height: | Size: 76 B |
25
cmd/core-ide/icons/icons.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// Package icons provides embedded icon assets for the Core IDE application.
|
||||||
|
package icons
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// TrayTemplate is the template icon for macOS systray (22x22 PNG, black on transparent).
|
||||||
|
// Template icons automatically adapt to light/dark mode on macOS.
|
||||||
|
//
|
||||||
|
//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
|
||||||
BIN
cmd/core-ide/icons/tray-dark.png
Normal file
|
After Width: | Height: | Size: 76 B |
BIN
cmd/core-ide/icons/tray-light.png
Normal file
|
After Width: | Height: | Size: 76 B |
BIN
cmd/core-ide/icons/tray-template.png
Normal file
|
After Width: | Height: | Size: 76 B |
102
cmd/core-ide/ide_service.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/mcp/ide"
|
||||||
|
"github.com/host-uk/core/pkg/ws"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IDEService provides core IDE bindings for the frontend.
|
||||||
|
type IDEService struct {
|
||||||
|
app *application.App
|
||||||
|
ideSub *ide.Subsystem
|
||||||
|
hub *ws.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIDEService creates a new IDEService.
|
||||||
|
func NewIDEService(ideSub *ide.Subsystem, hub *ws.Hub) *IDEService {
|
||||||
|
return &IDEService{ideSub: ideSub, hub: hub}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceName returns the service name for Wails.
|
||||||
|
func (s *IDEService) ServiceName() string { return "IDEService" }
|
||||||
|
|
||||||
|
// ServiceStartup is called when the Wails application starts.
|
||||||
|
func (s *IDEService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||||
|
// Start WebSocket HTTP server for the Angular frontend
|
||||||
|
go s.startWSServer()
|
||||||
|
log.Println("IDEService started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceShutdown is called when the Wails application shuts down.
|
||||||
|
func (s *IDEService) ServiceShutdown() error {
|
||||||
|
log.Println("IDEService shutdown")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionStatus represents the IDE bridge connection state.
|
||||||
|
type ConnectionStatus struct {
|
||||||
|
BridgeConnected bool `json:"bridgeConnected"`
|
||||||
|
LaravelURL string `json:"laravelUrl"`
|
||||||
|
WSClients int `json:"wsClients"`
|
||||||
|
WSChannels int `json:"wsChannels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnectionStatus returns the current bridge and WebSocket status.
|
||||||
|
func (s *IDEService) GetConnectionStatus() ConnectionStatus {
|
||||||
|
connected := false
|
||||||
|
if s.ideSub.Bridge() != nil {
|
||||||
|
connected = s.ideSub.Bridge().Connected()
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := s.hub.Stats()
|
||||||
|
return ConnectionStatus{
|
||||||
|
BridgeConnected: connected,
|
||||||
|
WSClients: stats.Clients,
|
||||||
|
WSChannels: stats.Channels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardData aggregates data for the dashboard view.
|
||||||
|
type DashboardData struct {
|
||||||
|
Connection ConnectionStatus `json:"connection"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboard returns aggregated dashboard data.
|
||||||
|
func (s *IDEService) GetDashboard() DashboardData {
|
||||||
|
return DashboardData{
|
||||||
|
Connection: s.GetConnectionStatus(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowWindow shows a named window.
|
||||||
|
func (s *IDEService) ShowWindow(name string) {
|
||||||
|
if s.app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w, ok := s.app.Window.Get(name); ok {
|
||||||
|
w.Show()
|
||||||
|
w.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWSServer starts the WebSocket HTTP server for the Angular frontend.
|
||||||
|
func (s *IDEService) startWSServer() {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/ws", s.hub.HandleWebSocket)
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
addr := "127.0.0.1:9877"
|
||||||
|
log.Printf("IDE WebSocket server listening on %s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
log.Printf("IDE WebSocket server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
151
cmd/core-ide/main.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
// Package main provides the Core IDE desktop application.
|
||||||
|
// Core IDE connects to the Laravel core-agentic backend via MCP bridge,
|
||||||
|
// providing a chat interface for AI agent sessions, build monitoring,
|
||||||
|
// and a system dashboard.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/cmd/core-ide/icons"
|
||||||
|
"github.com/host-uk/core/pkg/mcp/ide"
|
||||||
|
"github.com/host-uk/core/pkg/ws"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist/core-ide/browser
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
staticAssets, err := fs.Sub(assets, "frontend/dist/core-ide/browser")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create shared WebSocket hub for real-time streaming
|
||||||
|
hub := ws.NewHub()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go hub.Run(ctx)
|
||||||
|
|
||||||
|
// Create IDE subsystem (bridge to Laravel core-agentic)
|
||||||
|
ideSub := ide.New(hub)
|
||||||
|
ideSub.StartBridge(ctx)
|
||||||
|
|
||||||
|
// Create Wails services
|
||||||
|
ideService := NewIDEService(ideSub, hub)
|
||||||
|
chatService := NewChatService(ideSub)
|
||||||
|
buildService := NewBuildService(ideSub)
|
||||||
|
|
||||||
|
app := application.New(application.Options{
|
||||||
|
Name: "Core IDE",
|
||||||
|
Description: "Host UK Platform IDE - AI Agent Sessions, Build Monitoring & Dashboard",
|
||||||
|
Services: []application.Service{
|
||||||
|
application.NewService(ideService),
|
||||||
|
application.NewService(chatService),
|
||||||
|
application.NewService(buildService),
|
||||||
|
},
|
||||||
|
Assets: application.AssetOptions{
|
||||||
|
Handler: application.AssetFileServerFS(staticAssets),
|
||||||
|
},
|
||||||
|
Mac: application.MacOptions{
|
||||||
|
ActivationPolicy: application.ActivationPolicyAccessory,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ideService.app = app
|
||||||
|
|
||||||
|
setupSystemTray(app, ideService)
|
||||||
|
|
||||||
|
log.Println("Starting Core IDE...")
|
||||||
|
log.Println(" - System tray active")
|
||||||
|
log.Println(" - Bridge connecting to Laravel core-agentic...")
|
||||||
|
|
||||||
|
if err := app.Run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupSystemTray configures the system tray icon, menu, and windows.
|
||||||
|
func setupSystemTray(app *application.App, ideService *IDEService) {
|
||||||
|
systray := app.SystemTray.New()
|
||||||
|
systray.SetTooltip("Core IDE")
|
||||||
|
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
systray.SetTemplateIcon(icons.TrayTemplate)
|
||||||
|
} else {
|
||||||
|
systray.SetDarkModeIcon(icons.TrayDark)
|
||||||
|
systray.SetIcon(icons.TrayLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tray panel window
|
||||||
|
trayWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
|
Name: "tray-panel",
|
||||||
|
Title: "Core IDE",
|
||||||
|
Width: 400,
|
||||||
|
Height: 500,
|
||||||
|
URL: "/tray",
|
||||||
|
Hidden: true,
|
||||||
|
Frameless: true,
|
||||||
|
BackgroundColour: application.NewRGB(22, 27, 34),
|
||||||
|
})
|
||||||
|
systray.AttachWindow(trayWindow).WindowOffset(5)
|
||||||
|
|
||||||
|
// Main IDE window
|
||||||
|
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
|
Name: "main",
|
||||||
|
Title: "Core IDE",
|
||||||
|
Width: 1400,
|
||||||
|
Height: 900,
|
||||||
|
URL: "/main",
|
||||||
|
Hidden: true,
|
||||||
|
BackgroundColour: application.NewRGB(22, 27, 34),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Settings window
|
||||||
|
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
|
Name: "settings",
|
||||||
|
Title: "Core IDE Settings",
|
||||||
|
Width: 600,
|
||||||
|
Height: 500,
|
||||||
|
URL: "/settings",
|
||||||
|
Hidden: true,
|
||||||
|
BackgroundColour: application.NewRGB(22, 27, 34),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tray menu
|
||||||
|
trayMenu := app.Menu.New()
|
||||||
|
|
||||||
|
statusItem := trayMenu.Add("Status: Connecting...")
|
||||||
|
statusItem.SetEnabled(false)
|
||||||
|
|
||||||
|
trayMenu.AddSeparator()
|
||||||
|
|
||||||
|
trayMenu.Add("Open IDE").OnClick(func(ctx *application.Context) {
|
||||||
|
if w, ok := app.Window.Get("main"); ok {
|
||||||
|
w.Show()
|
||||||
|
w.Focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
trayMenu.Add("Settings...").OnClick(func(ctx *application.Context) {
|
||||||
|
if w, ok := app.Window.Get("settings"); ok {
|
||||||
|
w.Show()
|
||||||
|
w.Focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
trayMenu.AddSeparator()
|
||||||
|
|
||||||
|
trayMenu.Add("Quit Core IDE").OnClick(func(ctx *application.Context) {
|
||||||
|
app.Quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
systray.SetMenu(trayMenu)
|
||||||
|
}
|
||||||
403
github-projects-recovery.md
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
# GitHub Projects Recovery — host-uk org
|
||||||
|
|
||||||
|
> Recovered 2026-02-08 from flagged GitHub org before potential data loss.
|
||||||
|
> Projects 1 (Core.Framework) was empty. Projects 2, 3, 4 captured below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project 2: Workstation (43 items)
|
||||||
|
|
||||||
|
> Agentic task queue — issues labelled agent:ready across all host-uk repos.
|
||||||
|
|
||||||
|
| # | Title | Issue |
|
||||||
|
|---|-------|-------|
|
||||||
|
| 1 | feat: add workspace.yaml support for unified package commands | #38 |
|
||||||
|
| 2 | feat: add core setup command for GitHub repo configuration | #45 |
|
||||||
|
| 3 | docs sync ignores packages_dir from workspace.yaml | #46 |
|
||||||
|
| 4 | feat: add core qa command area for CI/workflow monitoring | #47 |
|
||||||
|
| 5 | feat: add core security command to expose Dependabot and code scanning alerts | #48 |
|
||||||
|
| 6 | feat: add core monitor to aggregate free tier scanner results | #49 |
|
||||||
|
| 7 | feat: add core qa issues for intelligent issue triage | #61 |
|
||||||
|
| 8 | feat: add core qa review for PR review status | #62 |
|
||||||
|
| 9 | feat: add core qa health for aggregate CI health | #63 |
|
||||||
|
| 10 | feat(dev): add safe git operations for AI agents | #53 |
|
||||||
|
| 11 | docs(mcp): Document MCP server setup and usage | #125 |
|
||||||
|
| 12 | feat: Implement persistent MCP server in daemon mode | #118 |
|
||||||
|
| 13 | chore(io): Migrate pkg/agentic to Medium abstraction | #104 |
|
||||||
|
| 14 | feat: Evolve pkg/io from Medium abstraction to io.Node (Borg + Enchantrix) | #101 |
|
||||||
|
| 15 | Add streaming API to pkg/io/local for large file handling | #224 |
|
||||||
|
| 16 | feat(hooks): Add core ai hook for async test running | #262 |
|
||||||
|
| 17 | feat(ai): Add core ai spawn for parallel agent tasks | #260 |
|
||||||
|
| 18 | feat(ai): Add core ai cost for budget tracking | #261 |
|
||||||
|
| 19 | feat(ai): Add core ai session for session management | #259 |
|
||||||
|
| 20 | feat(test): Add smart test detection to core test | #258 |
|
||||||
|
| 21 | feat(test): Add core test --watch continuous testing mode | #257 |
|
||||||
|
| 22 | feat(collect): Add core collect dispatch event hook system | #256 |
|
||||||
|
| 23 | feat(collect): Add core collect process command | #255 |
|
||||||
|
| 24 | feat(collect): Add core collect excavate command | #254 |
|
||||||
|
| 25 | feat(collect): Add core collect papers command | #253 |
|
||||||
|
| 26 | feat(collect): Add core collect bitcointalk command | #251 |
|
||||||
|
| 27 | feat(collect): Add core collect market command | #252 |
|
||||||
|
| 28 | feat(collect): Add core collect github command | #250 |
|
||||||
|
| 29 | epic(security): workspace isolation and authorisation hardening | #31 |
|
||||||
|
| 30 | epic(security): SQL query validation and execution safety | #32 |
|
||||||
|
| 31 | epic(fix): namespace and import corrections | #33 |
|
||||||
|
| 32 | epic(chore): configuration and documentation standardisation | #34 |
|
||||||
|
| 33 | Epic: Webhook Security Hardening | #27 |
|
||||||
|
| 34 | Epic: API Performance Optimisation | #28 |
|
||||||
|
| 35 | Epic: MCP API Hardening | #29 |
|
||||||
|
| 36 | Epic: API Test Coverage | #30 |
|
||||||
|
| 37 | Epic: Security Hardening | #104 |
|
||||||
|
| 38 | Epic: Input Validation & Sanitisation | #105 |
|
||||||
|
| 39 | Epic: Test Coverage | #106 |
|
||||||
|
| 40 | Epic: Error Handling & Observability | #107 |
|
||||||
|
| 41 | Epic: Performance Optimisation | #108 |
|
||||||
|
| 42 | Epic: Code Quality & Architecture | #109 |
|
||||||
|
| 43 | Epic: Documentation | #110 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project 4: Core.GO & Core.CLI (97 items)
|
||||||
|
|
||||||
|
> Go framework and CLI development — host-uk/core repo. Filter by lang:go label.
|
||||||
|
|
||||||
|
| # | Title | Issue |
|
||||||
|
|---|-------|-------|
|
||||||
|
| 1 | feat: add workspace.yaml support for unified package commands | #38 |
|
||||||
|
| 2 | feat: add core setup command for GitHub repo configuration | #45 |
|
||||||
|
| 3 | docs sync ignores packages_dir from workspace.yaml | #46 |
|
||||||
|
| 4 | feat: add core qa command area for CI/workflow monitoring | #47 |
|
||||||
|
| 5 | feat: add core security command to expose Dependabot and code scanning alerts | #48 |
|
||||||
|
| 6 | feat: add core monitor to aggregate free tier scanner results | #49 |
|
||||||
|
| 7 | feat(crypt): Implement standalone pkg/crypt with modern cryptographic primitives | #168 |
|
||||||
|
| 8 | feat(cli): Implement build variants for reduced attack surface | #171 |
|
||||||
|
| 9 | feat(config): Implement standalone pkg/config with layered configuration | #167 |
|
||||||
|
| 10 | feat(io): Fix pkg/io import and add symlink-safe path validation | #169 |
|
||||||
|
| 11 | feat(plugin): Consolidate pkg/module into pkg/plugin with GitHub installation | #170 |
|
||||||
|
| 12 | feat(help): Implement full-text search | #139 |
|
||||||
|
| 13 | feat(help): Implement Catalog and Topic types | #138 |
|
||||||
|
| 14 | feat(help): Implement markdown parsing and section extraction | #137 |
|
||||||
|
| 15 | feat(help): Remove Wails dependencies from pkg/help | #134 |
|
||||||
|
| 16 | feat(help): Add CLI help command | #136 |
|
||||||
|
| 17 | docs(help): Create help content for core CLI | #135 |
|
||||||
|
| 18 | feat(help): Implement display-agnostic help system for CLI and GUI | #133 |
|
||||||
|
| 19 | chore(log): Remove deprecated pkg/errors package | #131 |
|
||||||
|
| 20 | feat(log): Add combined log-and-return error helpers | #129 |
|
||||||
|
| 21 | chore(log): Create pkg/errors deprecation alias | #128 |
|
||||||
|
| 22 | feat(log): Unify pkg/errors and pkg/log into single logging package | #127 |
|
||||||
|
| 23 | feat(mcp): Add TCP transport | #126 |
|
||||||
|
| 24 | docs(mcp): Document MCP server setup and usage | #125 |
|
||||||
|
| 25 | feat(mcp): Add MCP command for manual server control | #124 |
|
||||||
|
| 26 | feat(mcp): Create MCPService for framework integration | #122 |
|
||||||
|
| 27 | feat(mcp): Add health check integration | #123 |
|
||||||
|
| 28 | chore(log): Migrate pkg/errors imports to pkg/log | #130 |
|
||||||
|
| 29 | feat(mcp): Add connection management and graceful draining | #121 |
|
||||||
|
| 30 | feat(mcp): Add daemon mode detection and auto-start | #119 |
|
||||||
|
| 31 | feat(mcp): Add Unix socket transport | #120 |
|
||||||
|
| 32 | feat: Implement persistent MCP server in daemon mode | #118 |
|
||||||
|
| 33 | chore(io): Migrate internal/cmd/setup to Medium abstraction | #116 |
|
||||||
|
| 34 | chore(io): Migrate internal/cmd/docs to Medium abstraction | #113 |
|
||||||
|
| 35 | chore(io): Migrate remaining internal/cmd/* to Medium abstraction | #117 |
|
||||||
|
| 36 | chore(io): Migrate internal/cmd/dev to Medium abstraction | #114 |
|
||||||
|
| 37 | chore(io): Migrate internal/cmd/sdk to Medium abstraction | #115 |
|
||||||
|
| 38 | chore(io): Migrate internal/cmd/php to Medium abstraction | #112 |
|
||||||
|
| 39 | feat(log): Add error creation functions to pkg/log | #132 |
|
||||||
|
| 40 | chore(io): Migrate pkg/cache to Medium abstraction | #111 |
|
||||||
|
| 41 | chore(io): Migrate pkg/devops to Medium abstraction | #110 |
|
||||||
|
| 42 | chore(io): Migrate pkg/cli to Medium abstraction | #107 |
|
||||||
|
| 43 | chore(io): Migrate pkg/build to Medium abstraction | #109 |
|
||||||
|
| 44 | chore(io): Migrate pkg/container to Medium abstraction | #105 |
|
||||||
|
| 45 | chore(io): Migrate pkg/repos to Medium abstraction | #108 |
|
||||||
|
| 46 | feat(io): Migrate pkg/mcp to use Medium abstraction | #103 |
|
||||||
|
| 47 | chore(io): Migrate pkg/release to Medium abstraction | #106 |
|
||||||
|
| 48 | chore(io): Migrate pkg/agentic to Medium abstraction | #104 |
|
||||||
|
| 49 | feat(io): Extend Medium interface with missing operations | #102 |
|
||||||
|
| 50 | fix(php): core php ci improvements needed | #92 |
|
||||||
|
| 51 | CLI Output: Color contrast audit and terminal adaptation | #99 |
|
||||||
|
| 52 | feat: Evolve pkg/io from Medium abstraction to io.Node (Borg + Enchantrix) | #101 |
|
||||||
|
| 53 | Documentation: Improve Accessibility | #89 |
|
||||||
|
| 54 | Web UI: Audit Angular App Accessibility | #88 |
|
||||||
|
| 55 | Add configuration documentation to README | #236 |
|
||||||
|
| 56 | Add Architecture Decision Records (ADRs) | #237 |
|
||||||
|
| 57 | Add user documentation: user guide, FAQ, troubleshooting guide | #235 |
|
||||||
|
| 58 | Add CHANGELOG.md to track version changes | #234 |
|
||||||
|
| 59 | Add CONTRIBUTING.md with contribution guidelines | #233 |
|
||||||
|
| 60 | Create centralized configuration service to reduce code duplication | #232 |
|
||||||
|
| 61 | Update README.md to reflect actual configuration management implementation | #231 |
|
||||||
|
| 62 | Centralize user-facing error strings in i18n translation files | #230 |
|
||||||
|
| 63 | Log all errors at handling point with contextual information | #229 |
|
||||||
|
| 64 | Implement panic recovery mechanism with graceful shutdown | #228 |
|
||||||
|
| 65 | Standardize on cli.Error for user-facing errors, deprecate cli.Fatal | #227 |
|
||||||
|
| 66 | Add linker flags (-s -w) to reduce binary size | #226 |
|
||||||
|
| 67 | Use background goroutines for long-running operations to prevent UI blocking | #225 |
|
||||||
|
| 68 | Add streaming API to pkg/io/local for large file handling | #224 |
|
||||||
|
| 69 | Fix Go environment to run govulncheck for dependency scanning | #223 |
|
||||||
|
| 70 | Sanitize user input in execInContainer to prevent injection | #222 |
|
||||||
|
| 71 | Configure branch coverage measurement in test tooling | #220 |
|
||||||
|
| 72 | Remove StrictHostKeyChecking=no from SSH commands | #221 |
|
||||||
|
| 73 | Implement authentication and authorization features described in README | #217 |
|
||||||
|
| 74 | Add tests for edge cases, error paths, and integration scenarios | #219 |
|
||||||
|
| 75 | Increase test coverage for low-coverage packages (cli, internal/cmd/dev) | #218 |
|
||||||
|
| 76 | Introduce typed messaging system for IPC (replace interface{}) | #216 |
|
||||||
|
| 77 | Refactor Core struct to smaller, focused components (ServiceManager, MessageBus, LifecycleManager) | #215 |
|
||||||
|
| 78 | Implement structured logging (JSON format) | #212 |
|
||||||
|
| 79 | Implement log retention policy | #214 |
|
||||||
|
| 80 | Add logging for security events (authentication, access) | #213 |
|
||||||
|
| 81 | feat(setup): add .core/setup.yaml for dev environment bootstrapping | #211 |
|
||||||
|
| 82 | audit: Documentation completeness and quality | #192 |
|
||||||
|
| 83 | audit: API design and consistency | #191 |
|
||||||
|
| 84 | [Audit] Concurrency and Race Condition Analysis | #197 |
|
||||||
|
| 85 | feat(hooks): Add core ai hook for async test running | #262 |
|
||||||
|
| 86 | feat(ai): Add core ai spawn for parallel agent tasks | #260 |
|
||||||
|
| 87 | feat(ai): Add core ai cost for budget tracking | #261 |
|
||||||
|
| 88 | feat(ai): Add core ai session for session management | #259 |
|
||||||
|
| 89 | feat(test): Add smart test detection to core test | #258 |
|
||||||
|
| 90 | feat(test): Add core test --watch continuous testing mode | #257 |
|
||||||
|
| 91 | feat(collect): Add core collect dispatch event hook system | #256 |
|
||||||
|
| 92 | feat(collect): Add core collect process command | #255 |
|
||||||
|
| 93 | feat(collect): Add core collect excavate command | #254 |
|
||||||
|
| 94 | feat(collect): Add core collect bitcointalk command | #251 |
|
||||||
|
| 95 | feat(collect): Add core collect papers command | #253 |
|
||||||
|
| 96 | feat(collect): Add core collect market command | #252 |
|
||||||
|
| 97 | feat(collect): Add core collect github command | #250 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project 3: Core.PHP (195 items)
|
||||||
|
|
||||||
|
> Laravel/PHP ecosystem — all core-* packages. Filter by lang:php label.
|
||||||
|
|
||||||
|
| # | Title | Issue |
|
||||||
|
|---|-------|-------|
|
||||||
|
| 1 | Dependency: Consider adding security scanning to CI pipeline | #31 |
|
||||||
|
| 2 | Concurrency: Sanitiser preset registration not thread-safe | #32 |
|
||||||
|
| 3 | Documentation: Missing SECURITY.md with vulnerability reporting process | #30 |
|
||||||
|
| 4 | Error Handling: ResilientSession redirect loop potential | #28 |
|
||||||
|
| 5 | Configuration: ConfigValue encryption may cause issues during APP_KEY rotation | #25 |
|
||||||
|
| 6 | Testing: Missing test coverage for critical security components | #23 |
|
||||||
|
| 7 | Security: HadesEncrypt embeds hardcoded public key | #21 |
|
||||||
|
| 8 | Security: SafeWebhookUrl DNS rebinding vulnerability | #17 |
|
||||||
|
| 9 | Performance: selectRaw queries may have missing indexes | #19 |
|
||||||
|
| 10 | Core Bouncer: Request Whitelisting System | #14 |
|
||||||
|
| 11 | Security: ManagesTokens trait stores tokens in memory without protection | #18 |
|
||||||
|
| 12 | Trees: Consolidate subscriber monthly command from Commerce module | #12 |
|
||||||
|
| 13 | Trees: Webhook/API for TFTF confirmation | #13 |
|
||||||
|
| 14 | CSRF token not automatically attached in bootstrap.js | #17 |
|
||||||
|
| 15 | Missing exception handling configuration in bootstrap/app.php | #15 |
|
||||||
|
| 16 | CI workflow only runs on main branch but repo uses dev as main | #14 |
|
||||||
|
| 17 | Minimal test coverage for a best-practices template | #16 |
|
||||||
|
| 18 | Missing declare(strict_types=1) in PHP files violates coding standards | #12 |
|
||||||
|
| 19 | Dependencies using dev-main branches instead of stable versions | #13 |
|
||||||
|
| 20 | Security: No HTTPS enforcement in production | #11 |
|
||||||
|
| 21 | Security: SESSION_ENCRYPT=false in .env.example is insecure default | #8 |
|
||||||
|
| 22 | Security: No rate limiting configured for any routes | #10 |
|
||||||
|
| 23 | Security: Missing security headers middleware by default | #9 |
|
||||||
|
| 24 | Security: ActivityLog query vulnerable to SQL wildcard injection | #20 |
|
||||||
|
| 25 | Missing: Rate limiting not applied to Livewire component methods | #17 |
|
||||||
|
| 26 | Missing: Log redaction patterns incomplete for common sensitive data | #16 |
|
||||||
|
| 27 | Code Quality: Livewire components duplicate checkHadesAccess() method | #19 |
|
||||||
|
| 28 | Error Handling: RemoteServerManager writeFile() has command injection via base64 | #15 |
|
||||||
|
| 29 | Missing: phpseclib3 not in composer.json dependencies | #18 |
|
||||||
|
| 30 | Performance: Query logging enabled unconditionally in local environment | #12 |
|
||||||
|
| 31 | Testing: Test suite does not verify Hades authorization enforcement | #11 |
|
||||||
|
| 32 | Error Handling: LogReaderService silently fails on file operations | #10 |
|
||||||
|
| 33 | Security: Telescope hides insufficient request headers in production | #14 |
|
||||||
|
| 34 | Security: IP validation missing for Server model | #13 |
|
||||||
|
| 35 | Security: Hades cookie has 1-year expiry with no rotation | #8 |
|
||||||
|
| 36 | Security: DevController authorize() method undefined | #7 |
|
||||||
|
| 37 | Security: Missing HADES_TOKEN configuration in .env.example | #9 |
|
||||||
|
| 38 | Security: Missing workspace authorization check when creating Server records | #6 |
|
||||||
|
| 39 | Security: SQL injection vulnerability in Database query tool - stacked query bypass | #4 |
|
||||||
|
| 40 | Security: Server SSH connection test uses StrictHostKeyChecking=no | #5 |
|
||||||
|
| 41 | Missing: Webhook endpoint URL scheme validation | #19 |
|
||||||
|
| 42 | Missing: Tests for WebhookSecretRotationService grace period edge cases | #20 |
|
||||||
|
| 43 | Performance: ApiUsageDaily recordFromUsage performs multiple queries | #18 |
|
||||||
|
| 44 | Security: API key scopes exposed in 403 error responses | #17 |
|
||||||
|
| 45 | Missing: Webhook delivery retry job lacks idempotency key | #15 |
|
||||||
|
| 46 | Configuration: No environment variable validation for API config | #16 |
|
||||||
|
| 47 | Error Handling: MCP registry YAML files read without validation | #14 |
|
||||||
|
| 48 | Missing: Index on webhook_deliveries for needsDelivery scope | #12 |
|
||||||
|
| 49 | Code Quality: WebhookSignature generateSecret uses Str::random instead of cryptographic RNG | #13 |
|
||||||
|
| 50 | Error Handling: recordUsage() called synchronously on every request | #10 |
|
||||||
|
| 51 | Security: Rate limit sliding window stores individual timestamps - memory growth concern | #9 |
|
||||||
|
| 52 | Security: WebhookSecretController lacks authorization checks | #11 |
|
||||||
|
| 53 | Security: Webhook secret visible in API response after rotation | #7 |
|
||||||
|
| 54 | Missing: Tests for MCP API Controller tool execution | #8 |
|
||||||
|
| 55 | Performance: API key lookup requires loading all candidates with matching prefix | #6 |
|
||||||
|
| 56 | Security: Webhook URL SSRF vulnerability - no validation of internal/private network URLs | #4 |
|
||||||
|
| 57 | Security: MCP tool execution uses proc_open without output sanitization | #5 |
|
||||||
|
| 58 | Missing tests for Social API controllers | #2 |
|
||||||
|
| 59 | Verify ProductApiController implementation | #3 |
|
||||||
|
| 60 | Session data stored without encryption (SESSION_ENCRYPT=false) | #18 |
|
||||||
|
| 61 | Mass assignment vulnerability in ContentEditor save method | #17 |
|
||||||
|
| 62 | AdminPageSearchProvider returns hardcoded URLs without auth checking | #16 |
|
||||||
|
| 63 | Missing rate limiting on sensitive admin operations | #14 |
|
||||||
|
| 64 | XSS risk in GlobalSearch component's JSON encoding | #13 |
|
||||||
|
| 65 | Missing validation for sortField parameter allows SQL injection | #10 |
|
||||||
|
| 66 | Missing test coverage for critical admin operations | #11 |
|
||||||
|
| 67 | Cache flush in Platform.php may cause service disruption | #12 |
|
||||||
|
| 68 | Missing CSRF protection for Livewire file uploads | #9 |
|
||||||
|
| 69 | N+1 query risk in ContentManager computed properties | #8 |
|
||||||
|
| 70 | Missing route authentication middleware on admin routes | #7 |
|
||||||
|
| 71 | Missing authorization check on Dashboard and Console components | #4 |
|
||||||
|
| 72 | SQL injection risk via LIKE wildcards in search queries | #5 |
|
||||||
|
| 73 | Bug: CheckMcpQuota middleware checks wrong attribute name | #22 |
|
||||||
|
| 74 | Security: DataRedactor does not handle object properties | #21 |
|
||||||
|
| 75 | Performance: QueryDatabase tool fetches all results before truncation | #20 |
|
||||||
|
| 76 | Documentation: Missing env validation for sensitive configuration | #23 |
|
||||||
|
| 77 | Security: McpAuditLog hash chain has race condition in transaction | #18 |
|
||||||
|
| 78 | Configuration: Missing MCP config file with database and security settings | #17 |
|
||||||
|
| 79 | Security: ApiKeyManager Livewire component missing CSRF and rate limiting | #19 |
|
||||||
|
| 80 | Error Handling: QueryExecutionService swallows timeout configuration errors | #16 |
|
||||||
|
| 81 | Security: SqlQueryValidator whitelist regex may allow SQL injection via JOINs | #15 |
|
||||||
|
| 82 | Test Coverage: Missing tests for critical security components | #14 |
|
||||||
|
| 83 | Security: McpApiController namespace mismatch and missing authorization | #11 |
|
||||||
|
| 84 | Security: AuditLogService export method has no authorization check | #13 |
|
||||||
|
| 85 | Bug: UpgradePlan tool imports RequiresWorkspaceContext from wrong namespace | #10 |
|
||||||
|
| 86 | Security: McpAuthenticate accepts API key in query string | #8 |
|
||||||
|
| 87 | Performance: AuditLogService hash chain verification loads entire log table | #12 |
|
||||||
|
| 88 | Bug: CircuitBreaker imports wrong namespace for CircuitOpenException | #9 |
|
||||||
|
| 89 | Security: ListTables tool uses MySQL-specific SHOW TABLES query | #7 |
|
||||||
|
| 90 | Security: ListTables tool exposes all database tables without authorization | #6 |
|
||||||
|
| 91 | Security: CreateCoupon tool missing strict_types declaration | #4 |
|
||||||
|
| 92 | Multi-server federation for MCP | #3 |
|
||||||
|
| 93 | Security: CreateCoupon tool missing workspace context/authorization | #5 |
|
||||||
|
| 94 | WebSocket support for real-time MCP updates | #2 |
|
||||||
|
| 95 | Incomplete account deletion may leave orphaned data | #13 |
|
||||||
|
| 96 | Error handling gap: Webhook secret returned in creation response | #14 |
|
||||||
|
| 97 | Missing environment validation for sensitive configuration | #18 |
|
||||||
|
| 98 | Potential timing attack in invitation token verification | #17 |
|
||||||
|
| 99 | Race condition in workspace default switching | #11 |
|
||||||
|
| 100 | Missing test coverage for TotpService TOTP verification | #12 |
|
||||||
|
| 101 | Missing authorisation check in EntitlementApiController::summary | #10 |
|
||||||
|
| 102 | Missing rate limiting on sensitive entitlement API endpoints | #9 |
|
||||||
|
| 103 | Security: Hardcoded test credentials in DemoTestUserSeeder | #7 |
|
||||||
|
| 104 | Security: SQL injection-like pattern in search query | #8 |
|
||||||
|
| 105 | Complete UserStatsService TODO items | #2 |
|
||||||
|
| 106 | Security: SSRF protection missing DNS rebinding defence in webhook dispatch job | #6 |
|
||||||
|
| 107 | Refund::markAsSucceeded not wrapped in transaction with payment update | #28 |
|
||||||
|
| 108 | Missing strict_types in Refund model | #30 |
|
||||||
|
| 109 | CreditNoteService::autoApplyCredits lacks transaction wrapper | #27 |
|
||||||
|
| 110 | Fail-open VAT validation could allow tax evasion | #25 |
|
||||||
|
| 111 | Missing strict_types in CreditNote model | #29 |
|
||||||
|
| 112 | Missing tests for CommerceController API endpoints | #26 |
|
||||||
|
| 113 | API controller returns raw exception messages to clients | #22 |
|
||||||
|
| 114 | Missing rate limiting on Commerce API endpoints | #23 |
|
||||||
|
| 115 | ProcessDunning console command lacks mutex/locking for concurrent runs | #24 |
|
||||||
|
| 116 | Race condition in CreditNote::recordUsage without row locking | #21 |
|
||||||
|
| 117 | Missing strict_types in PaymentMethodService.php | #20 |
|
||||||
|
| 118 | Missing strict_types in CreditNoteService.php | #19 |
|
||||||
|
| 119 | Missing tests for UsageBillingService | #16 |
|
||||||
|
| 120 | Missing strict_types in RefundService.php | #18 |
|
||||||
|
| 121 | Missing return type declarations in CreditNote model scopes | #14 |
|
||||||
|
| 122 | Missing tests for PaymentMethodService | #17 |
|
||||||
|
| 123 | MySQL-specific raw SQL breaks database portability | #13 |
|
||||||
|
| 124 | Missing strict_types declaration in UsageBillingService.php | #11 |
|
||||||
|
| 125 | Weak random number generation in CreditNote reference number | #12 |
|
||||||
|
| 126 | Missing tests for CreditNoteService | #15 |
|
||||||
|
| 127 | Missing tests for critical fraud detection paths | #9 |
|
||||||
|
| 128 | Missing strict_types declaration in TaxService.php | #10 |
|
||||||
|
| 129 | Missing index validation and SQL injection protection in Coupon scopes | #6 |
|
||||||
|
| 130 | Missing database transaction in referral payout commission assignment | #8 |
|
||||||
|
| 131 | Potential N+1 query in StripeGateway::createCheckoutSession | #7 |
|
||||||
|
| 132 | Race condition in Order number generation | #5 |
|
||||||
|
| 133 | Missing strict type declaration in SubscriptionService.php | #3 |
|
||||||
|
| 134 | Warehouse & Fulfillment System | #2 |
|
||||||
|
| 135 | Race condition in Invoice number generation | #4 |
|
||||||
|
| 136 | [Audit] Architecture Patterns | #50 |
|
||||||
|
| 137 | [Audit] Database Query Optimization | #48 |
|
||||||
|
| 138 | [Audit] Error Handling and Recovery | #51 |
|
||||||
|
| 139 | [Audit] Concurrency and Race Condition Analysis | #47 |
|
||||||
|
| 140 | audit: API design and consistency | #44 |
|
||||||
|
| 141 | audit: Performance bottlenecks and optimization | #43 |
|
||||||
|
| 142 | [Audit] Multi-Tenancy Security | #23 |
|
||||||
|
| 143 | fix(composer): simplify dependencies for hello world setup | #21 |
|
||||||
|
| 144 | [Audit] Database Query Optimization | #23 |
|
||||||
|
| 145 | audit: Test coverage and quality | #42 |
|
||||||
|
| 146 | audit: Code complexity and maintainability | #41 |
|
||||||
|
| 147 | audit: Authentication and authorization flows | #38 |
|
||||||
|
| 148 | audit: Dependency vulnerabilities and supply chain | #39 |
|
||||||
|
| 149 | [Audit] Database Query Optimization | #22 |
|
||||||
|
| 150 | audit: OWASP Top 10 security review | #36 |
|
||||||
|
| 151 | audit: Input validation and sanitization | #37 |
|
||||||
|
| 152 | security(mcp): ContentTools.php accepts workspace as request parameter enabling cross-tenant access | #29 |
|
||||||
|
| 153 | quality(mcp): standardise tool schema and request input patterns to match MCP spec | #30 |
|
||||||
|
| 154 | epic(security): workspace isolation and authorisation hardening | #31 |
|
||||||
|
| 155 | epic(security): SQL query validation and execution safety | #32 |
|
||||||
|
| 156 | epic(fix): namespace and import corrections | #33 |
|
||||||
|
| 157 | epic(chore): configuration and documentation standardisation | #34 |
|
||||||
|
| 158 | Epic: Webhook Security Hardening | #27 |
|
||||||
|
| 159 | Epic: API Performance Optimisation | #28 |
|
||||||
|
| 160 | Epic: MCP API Hardening | #29 |
|
||||||
|
| 161 | Epic: API Test Coverage | #30 |
|
||||||
|
| 162 | security(trees): fix race condition in PlantTreeWithTFTF job | #77 |
|
||||||
|
| 163 | security(auth): replace LthnHash with bcrypt for password hashing | #78 |
|
||||||
|
| 164 | security(helpers): fix SSRF in File.php via unvalidated Http::get | #79 |
|
||||||
|
| 165 | security(input): sanitise route parameters in Sanitiser middleware | #80 |
|
||||||
|
| 166 | security(trees): validate $model parameter in TreeStatsController | #81 |
|
||||||
|
| 167 | security(tests): remove hardcoded API token from test file | #82 |
|
||||||
|
| 168 | quality(bouncer): move env() call to config file in BouncerMiddleware | #83 |
|
||||||
|
| 169 | security(api): prevent upstream body leakage in BuildsResponse | #84 |
|
||||||
|
| 170 | security(auth): add session configuration file | #85 |
|
||||||
|
| 171 | quality(logging): add correlation IDs to request logging | #86 |
|
||||||
|
| 172 | security(logging): prevent PII leakage in LogsActivity trait | #87 |
|
||||||
|
| 173 | performance(queries): fix N+1 queries in ConfigResolver, AdminMenuRegistry, activity feed, SeoScoreTrend | #88 |
|
||||||
|
| 174 | performance(queries): replace ::all() with chunking/cursors | #89 |
|
||||||
|
| 175 | security(bouncer): review overly permissive bypass patterns | #90 |
|
||||||
|
| 176 | performance(http): add caching headers middleware | #91 |
|
||||||
|
| 177 | quality(scanner): refactor ModuleScanner namespace detection | #92 |
|
||||||
|
| 178 | security(input): extend superglobal sanitisation to cookies and server vars | #93 |
|
||||||
|
| 179 | docs(arch): add architecture diagram | #94 |
|
||||||
|
| 180 | docs(decisions): add Architecture Decision Records | #95 |
|
||||||
|
| 181 | docs(changelog): create formal changelog | #96 |
|
||||||
|
| 182 | docs(guide): add user guide, FAQ, and troubleshooting | #97 |
|
||||||
|
| 183 | quality(tenant): fix BelongsToWorkspace trait location discrepancy | #98 |
|
||||||
|
| 184 | quality(errors): implement custom exception hierarchy | #99 |
|
||||||
|
| 185 | quality(registry): reduce code duplication in ModuleRegistry | #100 |
|
||||||
|
| 186 | test(unit): add unit tests for src/ classes | #101 |
|
||||||
|
| 187 | test(security): add security-specific test suite | #102 |
|
||||||
|
| 188 | test(integration): add integration tests | #103 |
|
||||||
|
| 189 | Epic: Performance Optimisation | #108 |
|
||||||
|
| 190 | Epic: Code Quality & Architecture | #109 |
|
||||||
|
| 191 | Epic: Documentation | #110 |
|
||||||
|
| 192 | Epic: Input Validation & Sanitisation | #105 |
|
||||||
|
| 193 | Epic: Security Hardening | #104 |
|
||||||
|
| 194 | Epic: Test Coverage | #106 |
|
||||||
|
| 195 | Epic: Error Handling & Observability | #107 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Project | Items | Focus |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| #1 Core.Framework | 0 (empty) | 10,000ft architectural decisions |
|
||||||
|
| #2 Workstation | 43 | Agentic task queue, cross-repo |
|
||||||
|
| #3 Core.PHP | 195 | Laravel/PHP security, quality, tests |
|
||||||
|
| #4 Core.GO & Core.CLI | 97 | Go framework, CLI, MCP, io abstraction |
|
||||||
|
| **Total** | **335** | |
|
||||||
|
|
||||||
|
### Categories at a glance
|
||||||
|
|
||||||
|
**Core.PHP (#3)** — Dominated by security findings and audit results:
|
||||||
|
- ~60 security vulnerabilities (SQL injection, SSRF, XSS, auth bypass, race conditions)
|
||||||
|
- ~30 missing strict_types / coding standards
|
||||||
|
- ~25 missing test coverage
|
||||||
|
- ~15 performance issues (N+1 queries, missing indexes)
|
||||||
|
- ~10 epics grouping related work
|
||||||
|
- ~10 audit tasks
|
||||||
|
- Misc: docs, config, quality
|
||||||
|
|
||||||
|
**Core.GO (#4)** — Feature development and refactoring:
|
||||||
|
- ~15 io/Medium abstraction migrations
|
||||||
|
- ~10 MCP server features (transports, daemon, health)
|
||||||
|
- ~10 help system features
|
||||||
|
- ~8 log/error unification
|
||||||
|
- ~8 collect commands (data gathering)
|
||||||
|
- ~7 ai/test commands
|
||||||
|
- ~7 documentation/config audit
|
||||||
|
- Misc: security hardening, accessibility
|
||||||
|
|
||||||
|
**Workstation (#2)** — Subset of #3 and #4 tagged for agentic execution:
|
||||||
|
- Features ready for AI agent implementation
|
||||||
|
- Epics spanning both Go and PHP
|
||||||
18
go.mod
|
|
@ -38,6 +38,17 @@ require (
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/TwiN/go-color v1.4.1 // indirect
|
github.com/TwiN/go-color v1.4.1 // indirect
|
||||||
github.com/adrg/xdg v0.5.3 // indirect
|
github.com/adrg/xdg v0.5.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 // indirect
|
||||||
|
github.com/aws/smithy-go v1.24.0 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/brianvoe/gofakeit/v6 v6.28.0 // indirect
|
github.com/brianvoe/gofakeit/v6 v6.28.0 // indirect
|
||||||
|
|
@ -47,6 +58,7 @@ require (
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.9.1 // indirect
|
github.com/ebitengine/purego v0.9.1 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
|
|
@ -79,6 +91,7 @@ require (
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
|
@ -86,6 +99,7 @@ require (
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/samber/lo v1.52.0 // indirect
|
github.com/samber/lo v1.52.0 // indirect
|
||||||
|
|
@ -119,4 +133,8 @@ require (
|
||||||
google.golang.org/grpc v1.76.0 // indirect
|
google.golang.org/grpc v1.76.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.44.3 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
36
go.sum
|
|
@ -26,6 +26,28 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
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 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||||
|
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||||
|
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
|
@ -47,6 +69,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
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/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 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
|
|
@ -162,6 +186,8 @@ github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzo
|
||||||
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/oasdiff/oasdiff v1.11.9 h1:M/pIY4K1MWnML0DkAdUQU/CnJdNDr2z2hpD0lpKSccM=
|
github.com/oasdiff/oasdiff v1.11.9 h1:M/pIY4K1MWnML0DkAdUQU/CnJdNDr2z2hpD0lpKSccM=
|
||||||
github.com/oasdiff/oasdiff v1.11.9/go.mod h1:4qorAPsG2EE/lXEs+FGzAJcYHXS3G7XghfqkCFPKzNQ=
|
github.com/oasdiff/oasdiff v1.11.9/go.mod h1:4qorAPsG2EE/lXEs+FGzAJcYHXS3G7XghfqkCFPKzNQ=
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||||
|
|
@ -187,6 +213,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/qdrant/go-client v1.16.2 h1:UUMJJfvXTByhwhH1DwWdbkhZ2cTdvSqVkXSIfBrVWSg=
|
github.com/qdrant/go-client v1.16.2 h1:UUMJJfvXTByhwhH1DwWdbkhZ2cTdvSqVkXSIfBrVWSg=
|
||||||
github.com/qdrant/go-client v1.16.2/go.mod h1:I+EL3h4HRoRTeHtbfOd/4kDXwCukZfkd41j/9wryGkw=
|
github.com/qdrant/go-client v1.16.2/go.mod h1:I+EL3h4HRoRTeHtbfOd/4kDXwCukZfkd41j/9wryGkw=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
|
@ -338,3 +366,11 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||||
|
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
|
|
|
||||||
11
go.work
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
go 1.25.5
|
||||||
|
|
||||||
|
use (
|
||||||
|
.
|
||||||
|
./cmd/bugseti
|
||||||
|
./cmd/core-app
|
||||||
|
./cmd/core-ide
|
||||||
|
./internal/bugseti
|
||||||
|
./internal/bugseti/updater
|
||||||
|
./internal/core-ide
|
||||||
|
)
|
||||||
|
|
@ -7,9 +7,26 @@ import (
|
||||||
"github.com/host-uk/core/internal/cmd/workspace"
|
"github.com/host-uk/core/internal/cmd/workspace"
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultMedium is the default filesystem medium used by the php package.
|
||||||
|
// It defaults to io.Local (unsandboxed filesystem access).
|
||||||
|
// Use SetMedium to change this for testing or sandboxed operation.
|
||||||
|
var DefaultMedium io.Medium = io.Local
|
||||||
|
|
||||||
|
// SetMedium sets the default medium for filesystem operations.
|
||||||
|
// This is primarily useful for testing with mock mediums.
|
||||||
|
func SetMedium(m io.Medium) {
|
||||||
|
DefaultMedium = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMedium returns the default medium for filesystem operations.
|
||||||
|
func getMedium() io.Medium {
|
||||||
|
return DefaultMedium
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cli.RegisterCommands(AddPHPCommands)
|
cli.RegisterCommands(AddPHPCommands)
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +106,7 @@ func AddPHPCommands(root *cobra.Command) {
|
||||||
targetDir := filepath.Join(pkgDir, config.Active)
|
targetDir := filepath.Join(pkgDir, config.Active)
|
||||||
|
|
||||||
// Check if target directory exists
|
// Check if target directory exists
|
||||||
if _, err := os.Stat(targetDir); err != nil {
|
if !getMedium().IsDir(targetDir) {
|
||||||
cli.Warnf("Active package directory not found: %s", targetDir)
|
cli.Warnf("Active package directory not found: %s", targetDir)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -515,7 +515,7 @@ func generateSARIF(ctx context.Context, dir, checkName, outputFile string) error
|
||||||
return fmt.Errorf("invalid SARIF output: %w", err)
|
return fmt.Errorf("invalid SARIF output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(outputFile, output, 0644)
|
return getMedium().Write(outputFile, string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadSARIFToGitHub uploads a SARIF file to GitHub Security tab
|
// uploadSARIFToGitHub uploads a SARIF file to GitHub Security tab
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package php
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -77,6 +76,7 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "fmt":
|
case "fmt":
|
||||||
|
m := getMedium()
|
||||||
formatter, found := DetectFormatter(r.dir)
|
formatter, found := DetectFormatter(r.dir)
|
||||||
if !found {
|
if !found {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -84,7 +84,7 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
||||||
if formatter == FormatterPint {
|
if formatter == FormatterPint {
|
||||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint")
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint")
|
||||||
cmd := "pint"
|
cmd := "pint"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.IsFile(vendorBin) {
|
||||||
cmd = vendorBin
|
cmd = vendorBin
|
||||||
}
|
}
|
||||||
args := []string{}
|
args := []string{}
|
||||||
|
|
@ -102,13 +102,14 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "stan":
|
case "stan":
|
||||||
|
m := getMedium()
|
||||||
_, found := DetectAnalyser(r.dir)
|
_, found := DetectAnalyser(r.dir)
|
||||||
if !found {
|
if !found {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan")
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan")
|
||||||
cmd := "phpstan"
|
cmd := "phpstan"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.IsFile(vendorBin) {
|
||||||
cmd = vendorBin
|
cmd = vendorBin
|
||||||
}
|
}
|
||||||
return &process.RunSpec{
|
return &process.RunSpec{
|
||||||
|
|
@ -120,13 +121,14 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "psalm":
|
case "psalm":
|
||||||
|
m := getMedium()
|
||||||
_, found := DetectPsalm(r.dir)
|
_, found := DetectPsalm(r.dir)
|
||||||
if !found {
|
if !found {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm")
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm")
|
||||||
cmd := "psalm"
|
cmd := "psalm"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.IsFile(vendorBin) {
|
||||||
cmd = vendorBin
|
cmd = vendorBin
|
||||||
}
|
}
|
||||||
args := []string{"--no-progress"}
|
args := []string{"--no-progress"}
|
||||||
|
|
@ -142,14 +144,15 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "test":
|
case "test":
|
||||||
|
m := getMedium()
|
||||||
// Check for Pest first, fall back to PHPUnit
|
// Check for Pest first, fall back to PHPUnit
|
||||||
pestBin := filepath.Join(r.dir, "vendor", "bin", "pest")
|
pestBin := filepath.Join(r.dir, "vendor", "bin", "pest")
|
||||||
phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit")
|
phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit")
|
||||||
|
|
||||||
var cmd string
|
var cmd string
|
||||||
if _, err := os.Stat(pestBin); err == nil {
|
if m.IsFile(pestBin) {
|
||||||
cmd = pestBin
|
cmd = pestBin
|
||||||
} else if _, err := os.Stat(phpunitBin); err == nil {
|
} else if m.IsFile(phpunitBin) {
|
||||||
cmd = phpunitBin
|
cmd = phpunitBin
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -170,12 +173,13 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "rector":
|
case "rector":
|
||||||
|
m := getMedium()
|
||||||
if !DetectRector(r.dir) {
|
if !DetectRector(r.dir) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector")
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector")
|
||||||
cmd := "rector"
|
cmd := "rector"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.IsFile(vendorBin) {
|
||||||
cmd = vendorBin
|
cmd = vendorBin
|
||||||
}
|
}
|
||||||
args := []string{"process"}
|
args := []string{"process"}
|
||||||
|
|
@ -192,12 +196,13 @@ func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "infection":
|
case "infection":
|
||||||
|
m := getMedium()
|
||||||
if !DetectInfection(r.dir) {
|
if !DetectInfection(r.dir) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection")
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection")
|
||||||
cmd := "infection"
|
cmd := "infection"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.IsFile(vendorBin) {
|
||||||
cmd = vendorBin
|
cmd = vendorBin
|
||||||
}
|
}
|
||||||
return &process.RunSpec{
|
return &process.RunSpec{
|
||||||
|
|
|
||||||
|
|
@ -128,11 +128,12 @@ func BuildDocker(ctx context.Context, opts DockerBuildOptions) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to temporary file
|
// Write to temporary file
|
||||||
|
m := getMedium()
|
||||||
tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated")
|
tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated")
|
||||||
if err := os.WriteFile(tempDockerfile, []byte(content), 0644); err != nil {
|
if err := m.Write(tempDockerfile, content); err != nil {
|
||||||
return cli.WrapVerb(err, "write", "Dockerfile")
|
return cli.WrapVerb(err, "write", "Dockerfile")
|
||||||
}
|
}
|
||||||
defer func() { _ = os.Remove(tempDockerfile) }()
|
defer func() { _ = m.Delete(tempDockerfile) }()
|
||||||
|
|
||||||
dockerfilePath = tempDockerfile
|
dockerfilePath = tempDockerfile
|
||||||
}
|
}
|
||||||
|
|
@ -198,8 +199,9 @@ func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure output directory exists
|
// Ensure output directory exists
|
||||||
|
m := getMedium()
|
||||||
outputDir := filepath.Dir(opts.OutputPath)
|
outputDir := filepath.Dir(opts.OutputPath)
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := m.EnsureDir(outputDir); err != nil {
|
||||||
return cli.WrapVerb(err, "create", "output directory")
|
return cli.WrapVerb(err, "create", "output directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,10 +232,10 @@ func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error {
|
||||||
|
|
||||||
// Write template to temp file
|
// Write template to temp file
|
||||||
tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml")
|
tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml")
|
||||||
if err := os.WriteFile(tempYAML, []byte(content), 0644); err != nil {
|
if err := m.Write(tempYAML, content); err != nil {
|
||||||
return cli.WrapVerb(err, "write", "template")
|
return cli.WrapVerb(err, "write", "template")
|
||||||
}
|
}
|
||||||
defer func() { _ = os.Remove(tempYAML) }()
|
defer func() { _ = m.Delete(tempYAML) }()
|
||||||
|
|
||||||
// Build LinuxKit image
|
// Build LinuxKit image
|
||||||
args := []string{
|
args := []string{
|
||||||
|
|
@ -345,8 +347,7 @@ func Shell(ctx context.Context, containerID string) error {
|
||||||
// IsPHPProject checks if the given directory is a PHP project.
|
// IsPHPProject checks if the given directory is a PHP project.
|
||||||
func IsPHPProject(dir string) bool {
|
func IsPHPProject(dir string) bool {
|
||||||
composerPath := filepath.Join(dir, "composer.json")
|
composerPath := filepath.Join(dir, "composer.json")
|
||||||
_, err := os.Stat(composerPath)
|
return getMedium().IsFile(composerPath)
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// commonLinuxKitPaths defines default search locations for linuxkit.
|
// commonLinuxKitPaths defines default search locations for linuxkit.
|
||||||
|
|
@ -362,8 +363,9 @@ func lookupLinuxKit() (string, error) {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m := getMedium()
|
||||||
for _, p := range commonLinuxKitPaths {
|
for _, p := range commonLinuxKitPaths {
|
||||||
if _, err := os.Stat(p); err == nil {
|
if m.IsFile(p) {
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ func LoadCoolifyConfig(dir string) (*CoolifyConfig, error) {
|
||||||
|
|
||||||
// LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file.
|
// LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file.
|
||||||
func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) {
|
func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) {
|
||||||
|
m := getMedium()
|
||||||
config := &CoolifyConfig{}
|
config := &CoolifyConfig{}
|
||||||
|
|
||||||
// First try environment variables
|
// First try environment variables
|
||||||
|
|
@ -84,23 +85,18 @@ func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) {
|
||||||
config.StagingAppID = os.Getenv("COOLIFY_STAGING_APP_ID")
|
config.StagingAppID = os.Getenv("COOLIFY_STAGING_APP_ID")
|
||||||
|
|
||||||
// Then try .env file
|
// Then try .env file
|
||||||
file, err := os.Open(path)
|
if !m.Exists(path) {
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
// No .env file, just use env vars
|
// No .env file, just use env vars
|
||||||
return validateCoolifyConfig(config)
|
return validateCoolifyConfig(config)
|
||||||
}
|
}
|
||||||
return nil, cli.WrapVerb(err, "open", ".env file")
|
|
||||||
}
|
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
|
|
||||||
content, err := io.ReadAll(file)
|
content, err := m.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.WrapVerb(err, "read", ".env file")
|
return nil, cli.WrapVerb(err, "read", ".env file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse .env file
|
// Parse .env file
|
||||||
lines := strings.Split(string(content), "\n")
|
lines := strings.Split(content, "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
package php
|
package php
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
@ -28,15 +26,17 @@ const (
|
||||||
// IsLaravelProject checks if the given directory is a Laravel project.
|
// IsLaravelProject checks if the given directory is a Laravel project.
|
||||||
// It looks for the presence of artisan file and laravel in composer.json.
|
// It looks for the presence of artisan file and laravel in composer.json.
|
||||||
func IsLaravelProject(dir string) bool {
|
func IsLaravelProject(dir string) bool {
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check for artisan file
|
// Check for artisan file
|
||||||
artisanPath := filepath.Join(dir, "artisan")
|
artisanPath := filepath.Join(dir, "artisan")
|
||||||
if _, err := os.Stat(artisanPath); os.IsNotExist(err) {
|
if !m.Exists(artisanPath) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check composer.json for laravel/framework
|
// Check composer.json for laravel/framework
|
||||||
composerPath := filepath.Join(dir, "composer.json")
|
composerPath := filepath.Join(dir, "composer.json")
|
||||||
data, err := os.ReadFile(composerPath)
|
data, err := m.Read(composerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +46,7 @@ func IsLaravelProject(dir string) bool {
|
||||||
RequireDev map[string]string `json:"require-dev"`
|
RequireDev map[string]string `json:"require-dev"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &composer); err != nil {
|
if err := json.Unmarshal([]byte(data), &composer); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,9 +66,11 @@ func IsLaravelProject(dir string) bool {
|
||||||
// IsFrankenPHPProject checks if the project is configured for FrankenPHP.
|
// IsFrankenPHPProject checks if the project is configured for FrankenPHP.
|
||||||
// It looks for laravel/octane with frankenphp driver.
|
// It looks for laravel/octane with frankenphp driver.
|
||||||
func IsFrankenPHPProject(dir string) bool {
|
func IsFrankenPHPProject(dir string) bool {
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check composer.json for laravel/octane
|
// Check composer.json for laravel/octane
|
||||||
composerPath := filepath.Join(dir, "composer.json")
|
composerPath := filepath.Join(dir, "composer.json")
|
||||||
data, err := os.ReadFile(composerPath)
|
data, err := m.Read(composerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +79,7 @@ func IsFrankenPHPProject(dir string) bool {
|
||||||
Require map[string]string `json:"require"`
|
Require map[string]string `json:"require"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &composer); err != nil {
|
if err := json.Unmarshal([]byte(data), &composer); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,18 +89,18 @@ func IsFrankenPHPProject(dir string) bool {
|
||||||
|
|
||||||
// Check octane config for frankenphp
|
// Check octane config for frankenphp
|
||||||
configPath := filepath.Join(dir, "config", "octane.php")
|
configPath := filepath.Join(dir, "config", "octane.php")
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if !m.Exists(configPath) {
|
||||||
// If no config exists but octane is installed, assume frankenphp
|
// If no config exists but octane is installed, assume frankenphp
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
configData, err := os.ReadFile(configPath)
|
configData, err := m.Read(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true // Assume frankenphp if we can't read config
|
return true // Assume frankenphp if we can't read config
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for frankenphp in the config
|
// Look for frankenphp in the config
|
||||||
return strings.Contains(string(configData), "frankenphp")
|
return strings.Contains(configData, "frankenphp")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectServices detects which services are needed based on project files.
|
// DetectServices detects which services are needed based on project files.
|
||||||
|
|
@ -135,6 +137,7 @@ func DetectServices(dir string) []DetectedService {
|
||||||
|
|
||||||
// hasVite checks if the project uses Vite.
|
// hasVite checks if the project uses Vite.
|
||||||
func hasVite(dir string) bool {
|
func hasVite(dir string) bool {
|
||||||
|
m := getMedium()
|
||||||
viteConfigs := []string{
|
viteConfigs := []string{
|
||||||
"vite.config.js",
|
"vite.config.js",
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
|
|
@ -143,7 +146,7 @@ func hasVite(dir string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, config := range viteConfigs {
|
for _, config := range viteConfigs {
|
||||||
if _, err := os.Stat(filepath.Join(dir, config)); err == nil {
|
if m.Exists(filepath.Join(dir, config)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,29 +157,27 @@ func hasVite(dir string) bool {
|
||||||
// hasHorizon checks if Laravel Horizon is configured.
|
// hasHorizon checks if Laravel Horizon is configured.
|
||||||
func hasHorizon(dir string) bool {
|
func hasHorizon(dir string) bool {
|
||||||
horizonConfig := filepath.Join(dir, "config", "horizon.php")
|
horizonConfig := filepath.Join(dir, "config", "horizon.php")
|
||||||
_, err := os.Stat(horizonConfig)
|
return getMedium().Exists(horizonConfig)
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasReverb checks if Laravel Reverb is configured.
|
// hasReverb checks if Laravel Reverb is configured.
|
||||||
func hasReverb(dir string) bool {
|
func hasReverb(dir string) bool {
|
||||||
reverbConfig := filepath.Join(dir, "config", "reverb.php")
|
reverbConfig := filepath.Join(dir, "config", "reverb.php")
|
||||||
_, err := os.Stat(reverbConfig)
|
return getMedium().Exists(reverbConfig)
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// needsRedis checks if the project uses Redis based on .env configuration.
|
// needsRedis checks if the project uses Redis based on .env configuration.
|
||||||
func needsRedis(dir string) bool {
|
func needsRedis(dir string) bool {
|
||||||
|
m := getMedium()
|
||||||
envPath := filepath.Join(dir, ".env")
|
envPath := filepath.Join(dir, ".env")
|
||||||
file, err := os.Open(envPath)
|
content, err := m.Read(envPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
lines := strings.Split(content, "\n")
|
||||||
for scanner.Scan() {
|
for _, line := range lines {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line = strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(line, "#") {
|
if strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +208,7 @@ func needsRedis(dir string) bool {
|
||||||
// DetectPackageManager detects which package manager is used in the project.
|
// DetectPackageManager detects which package manager is used in the project.
|
||||||
// Returns "npm", "pnpm", "yarn", or "bun".
|
// Returns "npm", "pnpm", "yarn", or "bun".
|
||||||
func DetectPackageManager(dir string) string {
|
func DetectPackageManager(dir string) string {
|
||||||
|
m := getMedium()
|
||||||
// Check for lock files in order of preference
|
// Check for lock files in order of preference
|
||||||
lockFiles := []struct {
|
lockFiles := []struct {
|
||||||
file string
|
file string
|
||||||
|
|
@ -219,7 +221,7 @@ func DetectPackageManager(dir string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, lf := range lockFiles {
|
for _, lf := range lockFiles {
|
||||||
if _, err := os.Stat(filepath.Join(dir, lf.file)); err == nil {
|
if m.Exists(filepath.Join(dir, lf.file)) {
|
||||||
return lf.manager
|
return lf.manager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,16 +232,16 @@ func DetectPackageManager(dir string) string {
|
||||||
|
|
||||||
// GetLaravelAppName extracts the application name from Laravel's .env file.
|
// GetLaravelAppName extracts the application name from Laravel's .env file.
|
||||||
func GetLaravelAppName(dir string) string {
|
func GetLaravelAppName(dir string) string {
|
||||||
|
m := getMedium()
|
||||||
envPath := filepath.Join(dir, ".env")
|
envPath := filepath.Join(dir, ".env")
|
||||||
file, err := os.Open(envPath)
|
content, err := m.Read(envPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
lines := strings.Split(content, "\n")
|
||||||
for scanner.Scan() {
|
for _, line := range lines {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line = strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(line, "APP_NAME=") {
|
if strings.HasPrefix(line, "APP_NAME=") {
|
||||||
value := strings.TrimPrefix(line, "APP_NAME=")
|
value := strings.TrimPrefix(line, "APP_NAME=")
|
||||||
// Remove quotes if present
|
// Remove quotes if present
|
||||||
|
|
@ -253,16 +255,16 @@ func GetLaravelAppName(dir string) string {
|
||||||
|
|
||||||
// GetLaravelAppURL extracts the application URL from Laravel's .env file.
|
// GetLaravelAppURL extracts the application URL from Laravel's .env file.
|
||||||
func GetLaravelAppURL(dir string) string {
|
func GetLaravelAppURL(dir string) string {
|
||||||
|
m := getMedium()
|
||||||
envPath := filepath.Join(dir, ".env")
|
envPath := filepath.Join(dir, ".env")
|
||||||
file, err := os.Open(envPath)
|
content, err := m.Read(envPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
lines := strings.Split(content, "\n")
|
||||||
for scanner.Scan() {
|
for _, line := range lines {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line = strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(line, "APP_URL=") {
|
if strings.HasPrefix(line, "APP_URL=") {
|
||||||
value := strings.TrimPrefix(line, "APP_URL=")
|
value := strings.TrimPrefix(line, "APP_URL=")
|
||||||
// Remove quotes if present
|
// Remove quotes if present
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package php
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -50,6 +49,7 @@ func GenerateDockerfile(dir string) (string, error) {
|
||||||
|
|
||||||
// DetectDockerfileConfig detects configuration from project files.
|
// DetectDockerfileConfig detects configuration from project files.
|
||||||
func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) {
|
func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) {
|
||||||
|
m := getMedium()
|
||||||
config := &DockerfileConfig{
|
config := &DockerfileConfig{
|
||||||
PHPVersion: "8.3",
|
PHPVersion: "8.3",
|
||||||
BaseImage: "dunglas/frankenphp",
|
BaseImage: "dunglas/frankenphp",
|
||||||
|
|
@ -58,13 +58,13 @@ func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) {
|
||||||
|
|
||||||
// Read composer.json
|
// Read composer.json
|
||||||
composerPath := filepath.Join(dir, "composer.json")
|
composerPath := filepath.Join(dir, "composer.json")
|
||||||
composerData, err := os.ReadFile(composerPath)
|
composerContent, err := m.Read(composerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.WrapVerb(err, "read", "composer.json")
|
return nil, cli.WrapVerb(err, "read", "composer.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
var composer ComposerJSON
|
var composer ComposerJSON
|
||||||
if err := json.Unmarshal(composerData, &composer); err != nil {
|
if err := json.Unmarshal([]byte(composerContent), &composer); err != nil {
|
||||||
return nil, cli.WrapVerb(err, "parse", "composer.json")
|
return nil, cli.WrapVerb(err, "parse", "composer.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,13 +318,14 @@ func extractPHPVersion(constraint string) string {
|
||||||
|
|
||||||
// hasNodeAssets checks if the project has frontend assets.
|
// hasNodeAssets checks if the project has frontend assets.
|
||||||
func hasNodeAssets(dir string) bool {
|
func hasNodeAssets(dir string) bool {
|
||||||
|
m := getMedium()
|
||||||
packageJSON := filepath.Join(dir, "package.json")
|
packageJSON := filepath.Join(dir, "package.json")
|
||||||
if _, err := os.Stat(packageJSON); err != nil {
|
if !m.IsFile(packageJSON) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for build script in package.json
|
// Check for build script in package.json
|
||||||
data, err := os.ReadFile(packageJSON)
|
content, err := m.Read(packageJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -333,7 +334,7 @@ func hasNodeAssets(dir string) bool {
|
||||||
Scripts map[string]string `json:"scripts"`
|
Scripts map[string]string `json:"scripts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
if err := json.Unmarshal([]byte(content), &pkg); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,14 +25,15 @@ type composerRepository struct {
|
||||||
|
|
||||||
// readComposerJSON reads and parses composer.json from the given directory.
|
// readComposerJSON reads and parses composer.json from the given directory.
|
||||||
func readComposerJSON(dir string) (map[string]json.RawMessage, error) {
|
func readComposerJSON(dir string) (map[string]json.RawMessage, error) {
|
||||||
|
m := getMedium()
|
||||||
composerPath := filepath.Join(dir, "composer.json")
|
composerPath := filepath.Join(dir, "composer.json")
|
||||||
data, err := os.ReadFile(composerPath)
|
content, err := m.Read(composerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.WrapVerb(err, "read", "composer.json")
|
return nil, cli.WrapVerb(err, "read", "composer.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
var raw map[string]json.RawMessage
|
var raw map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
if err := json.Unmarshal([]byte(content), &raw); err != nil {
|
||||||
return nil, cli.WrapVerb(err, "parse", "composer.json")
|
return nil, cli.WrapVerb(err, "parse", "composer.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ func readComposerJSON(dir string) (map[string]json.RawMessage, error) {
|
||||||
|
|
||||||
// writeComposerJSON writes the composer.json to the given directory.
|
// writeComposerJSON writes the composer.json to the given directory.
|
||||||
func writeComposerJSON(dir string, raw map[string]json.RawMessage) error {
|
func writeComposerJSON(dir string, raw map[string]json.RawMessage) error {
|
||||||
|
m := getMedium()
|
||||||
composerPath := filepath.Join(dir, "composer.json")
|
composerPath := filepath.Join(dir, "composer.json")
|
||||||
|
|
||||||
data, err := json.MarshalIndent(raw, "", " ")
|
data, err := json.MarshalIndent(raw, "", " ")
|
||||||
|
|
@ -49,9 +51,9 @@ func writeComposerJSON(dir string, raw map[string]json.RawMessage) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add trailing newline
|
// Add trailing newline
|
||||||
data = append(data, '\n')
|
content := string(data) + "\n"
|
||||||
|
|
||||||
if err := os.WriteFile(composerPath, data, 0644); err != nil {
|
if err := m.Write(composerPath, content); err != nil {
|
||||||
return cli.WrapVerb(err, "write", "composer.json")
|
return cli.WrapVerb(err, "write", "composer.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,8 +93,9 @@ func setRepositories(raw map[string]json.RawMessage, repos []composerRepository)
|
||||||
|
|
||||||
// getPackageInfo reads package name and version from a composer.json in the given path.
|
// getPackageInfo reads package name and version from a composer.json in the given path.
|
||||||
func getPackageInfo(packagePath string) (name, version string, err error) {
|
func getPackageInfo(packagePath string) (name, version string, err error) {
|
||||||
|
m := getMedium()
|
||||||
composerPath := filepath.Join(packagePath, "composer.json")
|
composerPath := filepath.Join(packagePath, "composer.json")
|
||||||
data, err := os.ReadFile(composerPath)
|
content, err := m.Read(composerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", cli.WrapVerb(err, "read", "package composer.json")
|
return "", "", cli.WrapVerb(err, "read", "package composer.json")
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +105,7 @@ func getPackageInfo(packagePath string) (name, version string, err error) {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
if err := json.Unmarshal([]byte(content), &pkg); err != nil {
|
||||||
return "", "", cli.WrapVerb(err, "parse", "package composer.json")
|
return "", "", cli.WrapVerb(err, "parse", "package composer.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package php
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
goio "io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -31,7 +31,7 @@ type FormatOptions struct {
|
||||||
Paths []string
|
Paths []string
|
||||||
|
|
||||||
// Output is the writer for output (defaults to os.Stdout).
|
// Output is the writer for output (defaults to os.Stdout).
|
||||||
Output io.Writer
|
Output goio.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyseOptions configures PHP static analysis.
|
// AnalyseOptions configures PHP static analysis.
|
||||||
|
|
@ -55,7 +55,7 @@ type AnalyseOptions struct {
|
||||||
SARIF bool
|
SARIF bool
|
||||||
|
|
||||||
// Output is the writer for output (defaults to os.Stdout).
|
// Output is the writer for output (defaults to os.Stdout).
|
||||||
Output io.Writer
|
Output goio.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatterType represents the detected formatter.
|
// FormatterType represents the detected formatter.
|
||||||
|
|
@ -80,15 +80,17 @@ const (
|
||||||
|
|
||||||
// DetectFormatter detects which formatter is available in the project.
|
// DetectFormatter detects which formatter is available in the project.
|
||||||
func DetectFormatter(dir string) (FormatterType, bool) {
|
func DetectFormatter(dir string) (FormatterType, bool) {
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check for Pint config
|
// Check for Pint config
|
||||||
pintConfig := filepath.Join(dir, "pint.json")
|
pintConfig := filepath.Join(dir, "pint.json")
|
||||||
if _, err := os.Stat(pintConfig); err == nil {
|
if m.Exists(pintConfig) {
|
||||||
return FormatterPint, true
|
return FormatterPint, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for vendor binary
|
// Check for vendor binary
|
||||||
pintBin := filepath.Join(dir, "vendor", "bin", "pint")
|
pintBin := filepath.Join(dir, "vendor", "bin", "pint")
|
||||||
if _, err := os.Stat(pintBin); err == nil {
|
if m.Exists(pintBin) {
|
||||||
return FormatterPint, true
|
return FormatterPint, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,34 +99,27 @@ func DetectFormatter(dir string) (FormatterType, bool) {
|
||||||
|
|
||||||
// DetectAnalyser detects which static analyser is available in the project.
|
// DetectAnalyser detects which static analyser is available in the project.
|
||||||
func DetectAnalyser(dir string) (AnalyserType, bool) {
|
func DetectAnalyser(dir string) (AnalyserType, bool) {
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check for PHPStan config
|
// Check for PHPStan config
|
||||||
phpstanConfig := filepath.Join(dir, "phpstan.neon")
|
phpstanConfig := filepath.Join(dir, "phpstan.neon")
|
||||||
phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist")
|
phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist")
|
||||||
|
|
||||||
hasConfig := false
|
hasConfig := m.Exists(phpstanConfig) || m.Exists(phpstanDistConfig)
|
||||||
if _, err := os.Stat(phpstanConfig); err == nil {
|
|
||||||
hasConfig = true
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(phpstanDistConfig); err == nil {
|
|
||||||
hasConfig = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for vendor binary
|
// Check for vendor binary
|
||||||
phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan")
|
phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan")
|
||||||
hasBin := false
|
hasBin := m.Exists(phpstanBin)
|
||||||
if _, err := os.Stat(phpstanBin); err == nil {
|
|
||||||
hasBin = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasConfig || hasBin {
|
if hasConfig || hasBin {
|
||||||
// Check if it's Larastan (Laravel-specific PHPStan)
|
// Check if it's Larastan (Laravel-specific PHPStan)
|
||||||
larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan")
|
larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan")
|
||||||
if _, err := os.Stat(larastanPath); err == nil {
|
if m.Exists(larastanPath) {
|
||||||
return AnalyserLarastan, true
|
return AnalyserLarastan, true
|
||||||
}
|
}
|
||||||
// Also check nunomaduro/larastan
|
// Also check nunomaduro/larastan
|
||||||
larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
|
larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan")
|
||||||
if _, err := os.Stat(larastanPath2); err == nil {
|
if m.Exists(larastanPath2) {
|
||||||
return AnalyserLarastan, true
|
return AnalyserLarastan, true
|
||||||
}
|
}
|
||||||
return AnalyserPHPStan, true
|
return AnalyserPHPStan, true
|
||||||
|
|
@ -207,10 +202,12 @@ func Analyse(ctx context.Context, opts AnalyseOptions) error {
|
||||||
|
|
||||||
// buildPintCommand builds the command for running Laravel Pint.
|
// buildPintCommand builds the command for running Laravel Pint.
|
||||||
func buildPintCommand(opts FormatOptions) (string, []string) {
|
func buildPintCommand(opts FormatOptions) (string, []string) {
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check for vendor binary first
|
// Check for vendor binary first
|
||||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint")
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint")
|
||||||
cmdName := "pint"
|
cmdName := "pint"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.Exists(vendorBin) {
|
||||||
cmdName = vendorBin
|
cmdName = vendorBin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,10 +233,12 @@ func buildPintCommand(opts FormatOptions) (string, []string) {
|
||||||
|
|
||||||
// buildPHPStanCommand builds the command for running PHPStan.
|
// buildPHPStanCommand builds the command for running PHPStan.
|
||||||
func buildPHPStanCommand(opts AnalyseOptions) (string, []string) {
|
func buildPHPStanCommand(opts AnalyseOptions) (string, []string) {
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check for vendor binary first
|
// Check for vendor binary first
|
||||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan")
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan")
|
||||||
cmdName := "phpstan"
|
cmdName := "phpstan"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.Exists(vendorBin) {
|
||||||
cmdName = vendorBin
|
cmdName = vendorBin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,7 +278,7 @@ type PsalmOptions struct {
|
||||||
ShowInfo bool // Show info-level issues
|
ShowInfo bool // Show info-level issues
|
||||||
JSON bool // Output in JSON format
|
JSON bool // Output in JSON format
|
||||||
SARIF bool // Output in SARIF format for GitHub Security tab
|
SARIF bool // Output in SARIF format for GitHub Security tab
|
||||||
Output io.Writer
|
Output goio.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// PsalmType represents the detected Psalm configuration.
|
// PsalmType represents the detected Psalm configuration.
|
||||||
|
|
@ -293,21 +292,17 @@ const (
|
||||||
|
|
||||||
// DetectPsalm checks if Psalm is available in the project.
|
// DetectPsalm checks if Psalm is available in the project.
|
||||||
func DetectPsalm(dir string) (PsalmType, bool) {
|
func DetectPsalm(dir string) (PsalmType, bool) {
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check for psalm.xml config
|
// Check for psalm.xml config
|
||||||
psalmConfig := filepath.Join(dir, "psalm.xml")
|
psalmConfig := filepath.Join(dir, "psalm.xml")
|
||||||
psalmDistConfig := filepath.Join(dir, "psalm.xml.dist")
|
psalmDistConfig := filepath.Join(dir, "psalm.xml.dist")
|
||||||
|
|
||||||
hasConfig := false
|
hasConfig := m.Exists(psalmConfig) || m.Exists(psalmDistConfig)
|
||||||
if _, err := os.Stat(psalmConfig); err == nil {
|
|
||||||
hasConfig = true
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(psalmDistConfig); err == nil {
|
|
||||||
hasConfig = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for vendor binary
|
// Check for vendor binary
|
||||||
psalmBin := filepath.Join(dir, "vendor", "bin", "psalm")
|
psalmBin := filepath.Join(dir, "vendor", "bin", "psalm")
|
||||||
if _, err := os.Stat(psalmBin); err == nil {
|
if m.Exists(psalmBin) {
|
||||||
return PsalmStandard, true
|
return PsalmStandard, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,10 +327,12 @@ func RunPsalm(ctx context.Context, opts PsalmOptions) error {
|
||||||
opts.Output = os.Stdout
|
opts.Output = os.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Build command
|
// Build command
|
||||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm")
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm")
|
||||||
cmdName := "psalm"
|
cmdName := "psalm"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.Exists(vendorBin) {
|
||||||
cmdName = vendorBin
|
cmdName = vendorBin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,7 +378,7 @@ type AuditOptions struct {
|
||||||
Dir string
|
Dir string
|
||||||
JSON bool // Output in JSON format
|
JSON bool // Output in JSON format
|
||||||
Fix bool // Auto-fix vulnerabilities (npm only)
|
Fix bool // Auto-fix vulnerabilities (npm only)
|
||||||
Output io.Writer
|
Output goio.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditResult holds the results of a security audit.
|
// AuditResult holds the results of a security audit.
|
||||||
|
|
@ -422,7 +419,7 @@ func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) {
|
||||||
results = append(results, composerResult)
|
results = append(results, composerResult)
|
||||||
|
|
||||||
// Run npm audit if package.json exists
|
// Run npm audit if package.json exists
|
||||||
if _, err := os.Stat(filepath.Join(opts.Dir, "package.json")); err == nil {
|
if getMedium().Exists(filepath.Join(opts.Dir, "package.json")) {
|
||||||
npmResult := runNpmAudit(ctx, opts)
|
npmResult := runNpmAudit(ctx, opts)
|
||||||
results = append(results, npmResult)
|
results = append(results, npmResult)
|
||||||
}
|
}
|
||||||
|
|
@ -533,20 +530,22 @@ type RectorOptions struct {
|
||||||
Fix bool // Apply changes (default is dry-run)
|
Fix bool // Apply changes (default is dry-run)
|
||||||
Diff bool // Show detailed diff
|
Diff bool // Show detailed diff
|
||||||
ClearCache bool // Clear cache before running
|
ClearCache bool // Clear cache before running
|
||||||
Output io.Writer
|
Output goio.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectRector checks if Rector is available in the project.
|
// DetectRector checks if Rector is available in the project.
|
||||||
func DetectRector(dir string) bool {
|
func DetectRector(dir string) bool {
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check for rector.php config
|
// Check for rector.php config
|
||||||
rectorConfig := filepath.Join(dir, "rector.php")
|
rectorConfig := filepath.Join(dir, "rector.php")
|
||||||
if _, err := os.Stat(rectorConfig); err == nil {
|
if m.Exists(rectorConfig) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for vendor binary
|
// Check for vendor binary
|
||||||
rectorBin := filepath.Join(dir, "vendor", "bin", "rector")
|
rectorBin := filepath.Join(dir, "vendor", "bin", "rector")
|
||||||
if _, err := os.Stat(rectorBin); err == nil {
|
if m.Exists(rectorBin) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -567,10 +566,12 @@ func RunRector(ctx context.Context, opts RectorOptions) error {
|
||||||
opts.Output = os.Stdout
|
opts.Output = os.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Build command
|
// Build command
|
||||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector")
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector")
|
||||||
cmdName := "rector"
|
cmdName := "rector"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.Exists(vendorBin) {
|
||||||
cmdName = vendorBin
|
cmdName = vendorBin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -608,22 +609,24 @@ type InfectionOptions struct {
|
||||||
Threads int // Number of parallel threads
|
Threads int // Number of parallel threads
|
||||||
Filter string // Filter files by pattern
|
Filter string // Filter files by pattern
|
||||||
OnlyCovered bool // Only mutate covered code
|
OnlyCovered bool // Only mutate covered code
|
||||||
Output io.Writer
|
Output goio.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectInfection checks if Infection is available in the project.
|
// DetectInfection checks if Infection is available in the project.
|
||||||
func DetectInfection(dir string) bool {
|
func DetectInfection(dir string) bool {
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check for infection config files
|
// Check for infection config files
|
||||||
configs := []string{"infection.json", "infection.json5", "infection.json.dist"}
|
configs := []string{"infection.json", "infection.json5", "infection.json.dist"}
|
||||||
for _, config := range configs {
|
for _, config := range configs {
|
||||||
if _, err := os.Stat(filepath.Join(dir, config)); err == nil {
|
if m.Exists(filepath.Join(dir, config)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for vendor binary
|
// Check for vendor binary
|
||||||
infectionBin := filepath.Join(dir, "vendor", "bin", "infection")
|
infectionBin := filepath.Join(dir, "vendor", "bin", "infection")
|
||||||
if _, err := os.Stat(infectionBin); err == nil {
|
if m.Exists(infectionBin) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -644,10 +647,12 @@ func RunInfection(ctx context.Context, opts InfectionOptions) error {
|
||||||
opts.Output = os.Stdout
|
opts.Output = os.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Build command
|
// Build command
|
||||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection")
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection")
|
||||||
cmdName := "infection"
|
cmdName := "infection"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.Exists(vendorBin) {
|
||||||
cmdName = vendorBin
|
cmdName = vendorBin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -780,7 +785,7 @@ type SecurityOptions struct {
|
||||||
JSON bool // Output in JSON format
|
JSON bool // Output in JSON format
|
||||||
SARIF bool // Output in SARIF format
|
SARIF bool // Output in SARIF format
|
||||||
URL string // URL to check HTTP headers (optional)
|
URL string // URL to check HTTP headers (optional)
|
||||||
Output io.Writer
|
Output goio.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecurityResult holds the results of security scanning.
|
// SecurityResult holds the results of security scanning.
|
||||||
|
|
@ -873,13 +878,14 @@ func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResu
|
||||||
func runEnvSecurityChecks(dir string) []SecurityCheck {
|
func runEnvSecurityChecks(dir string) []SecurityCheck {
|
||||||
var checks []SecurityCheck
|
var checks []SecurityCheck
|
||||||
|
|
||||||
|
m := getMedium()
|
||||||
envPath := filepath.Join(dir, ".env")
|
envPath := filepath.Join(dir, ".env")
|
||||||
envContent, err := os.ReadFile(envPath)
|
envContent, err := m.Read(envPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return checks
|
return checks
|
||||||
}
|
}
|
||||||
|
|
||||||
envLines := strings.Split(string(envContent), "\n")
|
envLines := strings.Split(envContent, "\n")
|
||||||
envMap := make(map[string]string)
|
envMap := make(map[string]string)
|
||||||
for _, line := range envLines {
|
for _, line := range envLines {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
|
|
@ -948,12 +954,13 @@ func runEnvSecurityChecks(dir string) []SecurityCheck {
|
||||||
|
|
||||||
func runFilesystemSecurityChecks(dir string) []SecurityCheck {
|
func runFilesystemSecurityChecks(dir string) []SecurityCheck {
|
||||||
var checks []SecurityCheck
|
var checks []SecurityCheck
|
||||||
|
m := getMedium()
|
||||||
|
|
||||||
// Check .env not in public
|
// Check .env not in public
|
||||||
publicEnvPaths := []string{"public/.env", "public_html/.env"}
|
publicEnvPaths := []string{"public/.env", "public_html/.env"}
|
||||||
for _, path := range publicEnvPaths {
|
for _, path := range publicEnvPaths {
|
||||||
fullPath := filepath.Join(dir, path)
|
fullPath := filepath.Join(dir, path)
|
||||||
if _, err := os.Stat(fullPath); err == nil {
|
if m.Exists(fullPath) {
|
||||||
checks = append(checks, SecurityCheck{
|
checks = append(checks, SecurityCheck{
|
||||||
ID: "env_not_public",
|
ID: "env_not_public",
|
||||||
Name: ".env Not Publicly Accessible",
|
Name: ".env Not Publicly Accessible",
|
||||||
|
|
@ -970,7 +977,7 @@ func runFilesystemSecurityChecks(dir string) []SecurityCheck {
|
||||||
publicGitPaths := []string{"public/.git", "public_html/.git"}
|
publicGitPaths := []string{"public/.git", "public_html/.git"}
|
||||||
for _, path := range publicGitPaths {
|
for _, path := range publicGitPaths {
|
||||||
fullPath := filepath.Join(dir, path)
|
fullPath := filepath.Join(dir, path)
|
||||||
if _, err := os.Stat(fullPath); err == nil {
|
if m.Exists(fullPath) {
|
||||||
checks = append(checks, SecurityCheck{
|
checks = append(checks, SecurityCheck{
|
||||||
ID: "git_not_public",
|
ID: "git_not_public",
|
||||||
Name: ".git Not Publicly Accessible",
|
Name: ".git Not Publicly Accessible",
|
||||||
|
|
|
||||||
|
|
@ -78,17 +78,24 @@ func (s *baseService) Logs(follow bool) (io.ReadCloser, error) {
|
||||||
return nil, cli.Err("no log file available for %s", s.name)
|
return nil, cli.Err("no log file available for %s", s.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open(s.logPath)
|
m := getMedium()
|
||||||
|
file, err := m.Open(s.logPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.WrapVerb(err, "open", "log file")
|
return nil, cli.WrapVerb(err, "open", "log file")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !follow {
|
if !follow {
|
||||||
return file, nil
|
return file.(io.ReadCloser), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For follow mode, return a tailing reader
|
// For follow mode, return a tailing reader
|
||||||
return newTailReader(file), nil
|
// Type assert to get the underlying *os.File for tailing
|
||||||
|
osFile, ok := file.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
file.Close()
|
||||||
|
return nil, cli.Err("log file is not a regular file")
|
||||||
|
}
|
||||||
|
return newTailReader(osFile), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *baseService) startProcess(ctx context.Context, cmdName string, args []string, env []string) error {
|
func (s *baseService) startProcess(ctx context.Context, cmdName string, args []string, env []string) error {
|
||||||
|
|
@ -100,16 +107,23 @@ func (s *baseService) startProcess(ctx context.Context, cmdName string, args []s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create log file
|
// Create log file
|
||||||
|
m := getMedium()
|
||||||
logDir := filepath.Join(s.dir, ".core", "logs")
|
logDir := filepath.Join(s.dir, ".core", "logs")
|
||||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
if err := m.EnsureDir(logDir); err != nil {
|
||||||
return cli.WrapVerb(err, "create", "log directory")
|
return cli.WrapVerb(err, "create", "log directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logPath = filepath.Join(logDir, cli.Sprintf("%s.log", strings.ToLower(s.name)))
|
s.logPath = filepath.Join(logDir, cli.Sprintf("%s.log", strings.ToLower(s.name)))
|
||||||
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
logWriter, err := m.Create(s.logPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.WrapVerb(err, "create", "log file")
|
return cli.WrapVerb(err, "create", "log file")
|
||||||
}
|
}
|
||||||
|
// Type assert to get the underlying *os.File for use with exec.Cmd
|
||||||
|
logFile, ok := logWriter.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
logWriter.Close()
|
||||||
|
return cli.Err("log file is not a regular file")
|
||||||
|
}
|
||||||
s.logFile = logFile
|
s.logFile = logFile
|
||||||
|
|
||||||
// Create command
|
// Create command
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type SSLOptions struct {
|
||||||
|
|
||||||
// GetSSLDir returns the SSL directory, creating it if necessary.
|
// GetSSLDir returns the SSL directory, creating it if necessary.
|
||||||
func GetSSLDir(opts SSLOptions) (string, error) {
|
func GetSSLDir(opts SSLOptions) (string, error) {
|
||||||
|
m := getMedium()
|
||||||
dir := opts.Dir
|
dir := opts.Dir
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
|
|
@ -31,7 +32,7 @@ func GetSSLDir(opts SSLOptions) (string, error) {
|
||||||
dir = filepath.Join(home, DefaultSSLDir)
|
dir = filepath.Join(home, DefaultSSLDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := m.EnsureDir(dir); err != nil {
|
||||||
return "", cli.WrapVerb(err, "create", "SSL directory")
|
return "", cli.WrapVerb(err, "create", "SSL directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,16 +54,17 @@ func CertPaths(domain string, opts SSLOptions) (certFile, keyFile string, err er
|
||||||
|
|
||||||
// CertsExist checks if SSL certificates exist for the given domain.
|
// CertsExist checks if SSL certificates exist for the given domain.
|
||||||
func CertsExist(domain string, opts SSLOptions) bool {
|
func CertsExist(domain string, opts SSLOptions) bool {
|
||||||
|
m := getMedium()
|
||||||
certFile, keyFile, err := CertPaths(domain, opts)
|
certFile, keyFile, err := CertPaths(domain, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
if !m.IsFile(certFile) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
|
if !m.IsFile(keyFile) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ const (
|
||||||
func DetectTestRunner(dir string) TestRunner {
|
func DetectTestRunner(dir string) TestRunner {
|
||||||
// Check for Pest
|
// Check for Pest
|
||||||
pestFile := filepath.Join(dir, "tests", "Pest.php")
|
pestFile := filepath.Join(dir, "tests", "Pest.php")
|
||||||
if _, err := os.Stat(pestFile); err == nil {
|
if getMedium().IsFile(pestFile) {
|
||||||
return TestRunnerPest
|
return TestRunnerPest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,10 +108,11 @@ func RunParallel(ctx context.Context, opts TestOptions) error {
|
||||||
|
|
||||||
// buildPestCommand builds the command for running Pest tests.
|
// buildPestCommand builds the command for running Pest tests.
|
||||||
func buildPestCommand(opts TestOptions) (string, []string) {
|
func buildPestCommand(opts TestOptions) (string, []string) {
|
||||||
|
m := getMedium()
|
||||||
// Check for vendor binary first
|
// Check for vendor binary first
|
||||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pest")
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pest")
|
||||||
cmdName := "pest"
|
cmdName := "pest"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.IsFile(vendorBin) {
|
||||||
cmdName = vendorBin
|
cmdName = vendorBin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,10 +150,11 @@ func buildPestCommand(opts TestOptions) (string, []string) {
|
||||||
|
|
||||||
// buildPHPUnitCommand builds the command for running PHPUnit tests.
|
// buildPHPUnitCommand builds the command for running PHPUnit tests.
|
||||||
func buildPHPUnitCommand(opts TestOptions) (string, []string) {
|
func buildPHPUnitCommand(opts TestOptions) (string, []string) {
|
||||||
|
m := getMedium()
|
||||||
// Check for vendor binary first
|
// Check for vendor binary first
|
||||||
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpunit")
|
vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpunit")
|
||||||
cmdName := "phpunit"
|
cmdName := "phpunit"
|
||||||
if _, err := os.Stat(vendorBin); err == nil {
|
if m.IsFile(vendorBin) {
|
||||||
cmdName = vendorBin
|
cmdName = vendorBin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +167,7 @@ func buildPHPUnitCommand(opts TestOptions) (string, []string) {
|
||||||
if opts.Parallel {
|
if opts.Parallel {
|
||||||
// PHPUnit uses paratest for parallel execution
|
// PHPUnit uses paratest for parallel execution
|
||||||
paratestBin := filepath.Join(opts.Dir, "vendor", "bin", "paratest")
|
paratestBin := filepath.Join(opts.Dir, "vendor", "bin", "paratest")
|
||||||
if _, err := os.Stat(paratestBin); err == nil {
|
if m.IsFile(paratestBin) {
|
||||||
cmdName = paratestBin
|
cmdName = paratestBin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
455
pkg/auth/auth.go
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
// Package auth implements OpenPGP challenge-response authentication with
|
||||||
|
// support for both online (HTTP) and air-gapped (file-based) transport.
|
||||||
|
//
|
||||||
|
// Ported from dAppServer's mod-auth/lethean.service.ts.
|
||||||
|
//
|
||||||
|
// Authentication Flow (Online):
|
||||||
|
//
|
||||||
|
// 1. Client sends public key to server
|
||||||
|
// 2. Server generates a random nonce, encrypts it with client's public key
|
||||||
|
// 3. Client decrypts the nonce and signs it with their private key
|
||||||
|
// 4. Server verifies the signature, creates a session token
|
||||||
|
//
|
||||||
|
// Authentication Flow (Air-Gapped / Courier):
|
||||||
|
//
|
||||||
|
// Same crypto but challenge/response are exchanged via files on a Medium.
|
||||||
|
//
|
||||||
|
// Storage Layout (via Medium):
|
||||||
|
//
|
||||||
|
// users/
|
||||||
|
// {userID}.pub PGP public key (armored)
|
||||||
|
// {userID}.key PGP private key (armored, password-encrypted)
|
||||||
|
// {userID}.rev Revocation certificate (placeholder)
|
||||||
|
// {userID}.json User metadata (encrypted with user's public key)
|
||||||
|
// {userID}.lthn LTHN password hash
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
coreerr "github.com/host-uk/core/pkg/framework/core"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/crypt/lthn"
|
||||||
|
"github.com/host-uk/core/pkg/crypt/pgp"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default durations for challenge and session lifetimes.
|
||||||
|
const (
|
||||||
|
DefaultChallengeTTL = 5 * time.Minute
|
||||||
|
DefaultSessionTTL = 24 * time.Hour
|
||||||
|
nonceBytes = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// protectedUsers lists usernames that cannot be deleted.
|
||||||
|
// The "server" user holds the server keypair; deleting it would
|
||||||
|
// permanently destroy all joining data and require a full rebuild.
|
||||||
|
var protectedUsers = map[string]bool{
|
||||||
|
"server": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// User represents a registered user with PGP credentials.
|
||||||
|
type User struct {
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
PasswordHash string `json:"password_hash"` // LTHN hash
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
LastLogin time.Time `json:"last_login"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Challenge is a PGP-encrypted nonce sent to a client during authentication.
|
||||||
|
type Challenge struct {
|
||||||
|
Nonce []byte `json:"nonce"`
|
||||||
|
Encrypted string `json:"encrypted"` // PGP-encrypted nonce (armored)
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents an authenticated session.
|
||||||
|
type Session struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures an Authenticator.
|
||||||
|
type Option func(*Authenticator)
|
||||||
|
|
||||||
|
// WithChallengeTTL sets the lifetime of a challenge before it expires.
|
||||||
|
func WithChallengeTTL(d time.Duration) Option {
|
||||||
|
return func(a *Authenticator) {
|
||||||
|
a.challengeTTL = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSessionTTL sets the lifetime of a session before it expires.
|
||||||
|
func WithSessionTTL(d time.Duration) Option {
|
||||||
|
return func(a *Authenticator) {
|
||||||
|
a.sessionTTL = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticator manages PGP-based challenge-response authentication.
|
||||||
|
// All user data and keys are persisted through an io.Medium, which may
|
||||||
|
// be backed by disk, memory (MockMedium), or any other storage backend.
|
||||||
|
type Authenticator struct {
|
||||||
|
medium io.Medium
|
||||||
|
sessions map[string]*Session
|
||||||
|
challenges map[string]*Challenge // userID -> pending challenge
|
||||||
|
mu sync.RWMutex
|
||||||
|
challengeTTL time.Duration
|
||||||
|
sessionTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an Authenticator that persists user data via the given Medium.
|
||||||
|
func New(m io.Medium, opts ...Option) *Authenticator {
|
||||||
|
a := &Authenticator{
|
||||||
|
medium: m,
|
||||||
|
sessions: make(map[string]*Session),
|
||||||
|
challenges: make(map[string]*Challenge),
|
||||||
|
challengeTTL: DefaultChallengeTTL,
|
||||||
|
sessionTTL: DefaultSessionTTL,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(a)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// userPath returns the storage path for a user artifact.
|
||||||
|
func userPath(userID, ext string) string {
|
||||||
|
return "users/" + userID + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register creates a new user account. It hashes the username with LTHN to
|
||||||
|
// produce a userID, generates a PGP keypair (protected by the given password),
|
||||||
|
// and persists the public key, private key, revocation placeholder, password
|
||||||
|
// hash, and encrypted metadata via the Medium.
|
||||||
|
func (a *Authenticator) Register(username, password string) (*User, error) {
|
||||||
|
const op = "auth.Register"
|
||||||
|
|
||||||
|
userID := lthn.Hash(username)
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
if a.medium.IsFile(userPath(userID, ".pub")) {
|
||||||
|
return nil, coreerr.E(op, "user already exists", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure users directory exists
|
||||||
|
if err := a.medium.EnsureDir("users"); err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to create users directory", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate PGP keypair
|
||||||
|
kp, err := pgp.CreateKeyPair(userID, userID+"@auth.local", password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to create PGP keypair", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store public key
|
||||||
|
if err := a.medium.Write(userPath(userID, ".pub"), kp.PublicKey); err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to write public key", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store private key (already encrypted by PGP if password is non-empty)
|
||||||
|
if err := a.medium.Write(userPath(userID, ".key"), kp.PrivateKey); err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to write private key", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store revocation certificate placeholder
|
||||||
|
if err := a.medium.Write(userPath(userID, ".rev"), "REVOCATION_PLACEHOLDER"); err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to write revocation certificate", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store LTHN password hash
|
||||||
|
passwordHash := lthn.Hash(password)
|
||||||
|
if err := a.medium.Write(userPath(userID, ".lthn"), passwordHash); err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to write password hash", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build user metadata
|
||||||
|
now := time.Now()
|
||||||
|
user := &User{
|
||||||
|
PublicKey: kp.PublicKey,
|
||||||
|
KeyID: userID,
|
||||||
|
Fingerprint: lthn.Hash(kp.PublicKey),
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Created: now,
|
||||||
|
LastLogin: time.Time{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt metadata with the user's public key and store
|
||||||
|
metaJSON, err := json.Marshal(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to marshal user metadata", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encMeta, err := pgp.Encrypt(metaJSON, kp.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to encrypt user metadata", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.medium.Write(userPath(userID, ".json"), string(encMeta)); err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to write user metadata", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateChallenge generates a cryptographic challenge for the given user.
|
||||||
|
// A random nonce is created and encrypted with the user's PGP public key.
|
||||||
|
// The client must decrypt the nonce and sign it to prove key ownership.
|
||||||
|
func (a *Authenticator) CreateChallenge(userID string) (*Challenge, error) {
|
||||||
|
const op = "auth.CreateChallenge"
|
||||||
|
|
||||||
|
// Read user's public key
|
||||||
|
pubKey, err := a.medium.Read(userPath(userID, ".pub"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E(op, "user not found", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random nonce
|
||||||
|
nonce := make([]byte, nonceBytes)
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to generate nonce", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt nonce with user's public key
|
||||||
|
encrypted, err := pgp.Encrypt(nonce, pubKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to encrypt nonce", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge := &Challenge{
|
||||||
|
Nonce: nonce,
|
||||||
|
Encrypted: string(encrypted),
|
||||||
|
ExpiresAt: time.Now().Add(a.challengeTTL),
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.challenges[userID] = challenge
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
return challenge, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateResponse verifies a signed nonce from the client. The client must
|
||||||
|
// have decrypted the challenge nonce and signed it with their private key.
|
||||||
|
// On success, a new session is created and returned.
|
||||||
|
func (a *Authenticator) ValidateResponse(userID string, signedNonce []byte) (*Session, error) {
|
||||||
|
const op = "auth.ValidateResponse"
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
challenge, exists := a.challenges[userID]
|
||||||
|
if exists {
|
||||||
|
delete(a.challenges, userID)
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, coreerr.E(op, "no pending challenge for user", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check challenge expiry
|
||||||
|
if time.Now().After(challenge.ExpiresAt) {
|
||||||
|
return nil, coreerr.E(op, "challenge expired", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read user's public key
|
||||||
|
pubKey, err := a.medium.Read(userPath(userID, ".pub"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E(op, "user not found", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature over the original nonce
|
||||||
|
if err := pgp.Verify(challenge.Nonce, signedNonce, pubKey); err != nil {
|
||||||
|
return nil, coreerr.E(op, "signature verification failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.createSession(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSession checks whether a token maps to a valid, non-expired session.
|
||||||
|
func (a *Authenticator) ValidateSession(token string) (*Session, error) {
|
||||||
|
const op = "auth.ValidateSession"
|
||||||
|
|
||||||
|
a.mu.RLock()
|
||||||
|
session, exists := a.sessions[token]
|
||||||
|
a.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, coreerr.E(op, "session not found", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(session.ExpiresAt) {
|
||||||
|
a.mu.Lock()
|
||||||
|
delete(a.sessions, token)
|
||||||
|
a.mu.Unlock()
|
||||||
|
return nil, coreerr.E(op, "session expired", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshSession extends the expiry of an existing valid session.
|
||||||
|
func (a *Authenticator) RefreshSession(token string) (*Session, error) {
|
||||||
|
const op = "auth.RefreshSession"
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
session, exists := a.sessions[token]
|
||||||
|
if !exists {
|
||||||
|
return nil, coreerr.E(op, "session not found", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(session.ExpiresAt) {
|
||||||
|
delete(a.sessions, token)
|
||||||
|
return nil, coreerr.E(op, "session expired", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.ExpiresAt = time.Now().Add(a.sessionTTL)
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeSession removes a session, invalidating the token immediately.
|
||||||
|
func (a *Authenticator) RevokeSession(token string) error {
|
||||||
|
const op = "auth.RevokeSession"
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := a.sessions[token]; !exists {
|
||||||
|
return coreerr.E(op, "session not found", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(a.sessions, token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a user and all associated keys from storage.
|
||||||
|
// The "server" user is protected and cannot be deleted (mirroring the
|
||||||
|
// original TypeScript implementation's safeguard).
|
||||||
|
func (a *Authenticator) DeleteUser(userID string) error {
|
||||||
|
const op = "auth.DeleteUser"
|
||||||
|
|
||||||
|
// Protect special users
|
||||||
|
if protectedUsers[userID] {
|
||||||
|
return coreerr.E(op, "cannot delete protected user", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user exists
|
||||||
|
if !a.medium.IsFile(userPath(userID, ".pub")) {
|
||||||
|
return coreerr.E(op, "user not found", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all artifacts
|
||||||
|
extensions := []string{".pub", ".key", ".rev", ".json", ".lthn"}
|
||||||
|
for _, ext := range extensions {
|
||||||
|
p := userPath(userID, ext)
|
||||||
|
if a.medium.IsFile(p) {
|
||||||
|
if err := a.medium.Delete(p); err != nil {
|
||||||
|
return coreerr.E(op, "failed to delete "+ext, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke any active sessions for this user
|
||||||
|
a.mu.Lock()
|
||||||
|
for token, session := range a.sessions {
|
||||||
|
if session.UserID == userID {
|
||||||
|
delete(a.sessions, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login performs password-based authentication as a convenience method.
|
||||||
|
// It verifies the password against the stored LTHN hash and, on success,
|
||||||
|
// creates a new session. This bypasses the PGP challenge-response flow.
|
||||||
|
func (a *Authenticator) Login(userID, password string) (*Session, error) {
|
||||||
|
const op = "auth.Login"
|
||||||
|
|
||||||
|
// Read stored password hash
|
||||||
|
storedHash, err := a.medium.Read(userPath(userID, ".lthn"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E(op, "user not found", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if !lthn.Verify(password, storedHash) {
|
||||||
|
return nil, coreerr.E(op, "invalid password", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.createSession(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteChallengeFile writes an encrypted challenge to a file for air-gapped
|
||||||
|
// (courier) transport. The challenge is created and then its encrypted nonce
|
||||||
|
// is written to the specified path on the Medium.
|
||||||
|
func (a *Authenticator) WriteChallengeFile(userID, path string) error {
|
||||||
|
const op = "auth.WriteChallengeFile"
|
||||||
|
|
||||||
|
challenge, err := a.CreateChallenge(userID)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E(op, "failed to create challenge", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(challenge)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E(op, "failed to marshal challenge", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.medium.Write(path, string(data)); err != nil {
|
||||||
|
return coreerr.E(op, "failed to write challenge file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadResponseFile reads a signed response from a file and validates it,
|
||||||
|
// completing the air-gapped authentication flow. The file must contain the
|
||||||
|
// raw PGP signature bytes (armored).
|
||||||
|
func (a *Authenticator) ReadResponseFile(userID, path string) (*Session, error) {
|
||||||
|
const op = "auth.ReadResponseFile"
|
||||||
|
|
||||||
|
content, err := a.medium.Read(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to read response file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := a.ValidateResponse(userID, []byte(content))
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E(op, "failed to validate response", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSession generates a cryptographically random session token and
|
||||||
|
// stores the session in the in-memory session map.
|
||||||
|
func (a *Authenticator) createSession(userID string) (*Session, error) {
|
||||||
|
tokenBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(tokenBytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: failed to generate session token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &Session{
|
||||||
|
Token: hex.EncodeToString(tokenBytes),
|
||||||
|
UserID: userID,
|
||||||
|
ExpiresAt: time.Now().Add(a.sessionTTL),
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.sessions[session.Token] = session
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
581
pkg/auth/auth_test.go
Normal file
|
|
@ -0,0 +1,581 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/crypt/lthn"
|
||||||
|
"github.com/host-uk/core/pkg/crypt/pgp"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helper creates a fresh Authenticator backed by MockMedium.
|
||||||
|
func newTestAuth(opts ...Option) (*Authenticator, *io.MockMedium) {
|
||||||
|
m := io.NewMockMedium()
|
||||||
|
a := New(m, opts...)
|
||||||
|
return a, m
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Register ---
|
||||||
|
|
||||||
|
func TestRegister_Good(t *testing.T) {
|
||||||
|
a, m := newTestAuth()
|
||||||
|
|
||||||
|
user, err := a.Register("alice", "hunter2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, user)
|
||||||
|
|
||||||
|
userID := lthn.Hash("alice")
|
||||||
|
|
||||||
|
// Verify public key is stored
|
||||||
|
assert.True(t, m.IsFile(userPath(userID, ".pub")))
|
||||||
|
assert.True(t, m.IsFile(userPath(userID, ".key")))
|
||||||
|
assert.True(t, m.IsFile(userPath(userID, ".rev")))
|
||||||
|
assert.True(t, m.IsFile(userPath(userID, ".json")))
|
||||||
|
assert.True(t, m.IsFile(userPath(userID, ".lthn")))
|
||||||
|
|
||||||
|
// Verify user fields
|
||||||
|
assert.NotEmpty(t, user.PublicKey)
|
||||||
|
assert.Equal(t, userID, user.KeyID)
|
||||||
|
assert.NotEmpty(t, user.Fingerprint)
|
||||||
|
assert.Equal(t, lthn.Hash("hunter2"), user.PasswordHash)
|
||||||
|
assert.False(t, user.Created.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Register first time succeeds
|
||||||
|
_, err := a.Register("bob", "pass1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Duplicate registration should fail
|
||||||
|
_, err = a.Register("bob", "pass2")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_Ugly(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Empty username/password should still work (PGP allows it)
|
||||||
|
user, err := a.Register("", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CreateChallenge ---
|
||||||
|
|
||||||
|
func TestCreateChallenge_Good(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
user, err := a.Register("charlie", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
challenge, err := a.CreateChallenge(user.KeyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, challenge)
|
||||||
|
|
||||||
|
assert.Len(t, challenge.Nonce, nonceBytes)
|
||||||
|
assert.NotEmpty(t, challenge.Encrypted)
|
||||||
|
assert.True(t, challenge.ExpiresAt.After(time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateChallenge_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Challenge for non-existent user
|
||||||
|
_, err := a.CreateChallenge("nonexistent-user-id")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateChallenge_Ugly(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Empty userID
|
||||||
|
_, err := a.CreateChallenge("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateResponse (full challenge-response flow) ---
|
||||||
|
|
||||||
|
func TestValidateResponse_Good(t *testing.T) {
|
||||||
|
a, m := newTestAuth()
|
||||||
|
|
||||||
|
// Register user
|
||||||
|
_, err := a.Register("dave", "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
userID := lthn.Hash("dave")
|
||||||
|
|
||||||
|
// Create challenge
|
||||||
|
challenge, err := a.CreateChallenge(userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Client-side: decrypt nonce, then sign it
|
||||||
|
privKey, err := m.Read(userPath(userID, ".key"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, challenge.Nonce, decryptedNonce)
|
||||||
|
|
||||||
|
signedNonce, err := pgp.Sign(decryptedNonce, privKey, "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Validate response
|
||||||
|
session, err := a.ValidateResponse(userID, signedNonce)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, session)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, session.Token)
|
||||||
|
assert.Equal(t, userID, session.UserID)
|
||||||
|
assert.True(t, session.ExpiresAt.After(time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateResponse_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.Register("eve", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("eve")
|
||||||
|
|
||||||
|
// No pending challenge
|
||||||
|
_, err = a.ValidateResponse(userID, []byte("fake-signature"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no pending challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateResponse_Ugly(t *testing.T) {
|
||||||
|
a, m := newTestAuth(WithChallengeTTL(1 * time.Millisecond))
|
||||||
|
|
||||||
|
_, err := a.Register("frank", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("frank")
|
||||||
|
|
||||||
|
// Create challenge and let it expire
|
||||||
|
challenge, err := a.CreateChallenge(userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
// Sign with valid key but expired challenge
|
||||||
|
privKey, err := m.Read(userPath(userID, ".key"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
signedNonce, err := pgp.Sign(challenge.Nonce, privKey, "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = a.ValidateResponse(userID, signedNonce)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "challenge expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateSession ---
|
||||||
|
|
||||||
|
func TestValidateSession_Good(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.Register("grace", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("grace")
|
||||||
|
|
||||||
|
session, err := a.Login(userID, "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
validated, err := a.ValidateSession(session.Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, session.Token, validated.Token)
|
||||||
|
assert.Equal(t, userID, validated.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSession_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.ValidateSession("nonexistent-token")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSession_Ugly(t *testing.T) {
|
||||||
|
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
|
||||||
|
|
||||||
|
_, err := a.Register("heidi", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("heidi")
|
||||||
|
|
||||||
|
session, err := a.Login(userID, "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
_, err = a.ValidateSession(session.Token)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "session expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RefreshSession ---
|
||||||
|
|
||||||
|
func TestRefreshSession_Good(t *testing.T) {
|
||||||
|
a, _ := newTestAuth(WithSessionTTL(1 * time.Hour))
|
||||||
|
|
||||||
|
_, err := a.Register("ivan", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("ivan")
|
||||||
|
|
||||||
|
session, err := a.Login(userID, "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
originalExpiry := session.ExpiresAt
|
||||||
|
|
||||||
|
// Small delay to ensure time moves forward
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
|
||||||
|
refreshed, err := a.RefreshSession(session.Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, refreshed.ExpiresAt.After(originalExpiry))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshSession_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.RefreshSession("nonexistent-token")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshSession_Ugly(t *testing.T) {
|
||||||
|
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
|
||||||
|
|
||||||
|
_, err := a.Register("judy", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("judy")
|
||||||
|
|
||||||
|
session, err := a.Login(userID, "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
_, err = a.RefreshSession(session.Token)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "session expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RevokeSession ---
|
||||||
|
|
||||||
|
func TestRevokeSession_Good(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.Register("karl", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("karl")
|
||||||
|
|
||||||
|
session, err := a.Login(userID, "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = a.RevokeSession(session.Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Token should no longer be valid
|
||||||
|
_, err = a.ValidateSession(session.Token)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeSession_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
err := a.RevokeSession("nonexistent-token")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeSession_Ugly(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Revoke empty token
|
||||||
|
err := a.RevokeSession("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DeleteUser ---
|
||||||
|
|
||||||
|
func TestDeleteUser_Good(t *testing.T) {
|
||||||
|
a, m := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.Register("larry", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("larry")
|
||||||
|
|
||||||
|
// Also create a session that should be cleaned up
|
||||||
|
_, err = a.Login(userID, "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = a.DeleteUser(userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// All files should be gone
|
||||||
|
assert.False(t, m.IsFile(userPath(userID, ".pub")))
|
||||||
|
assert.False(t, m.IsFile(userPath(userID, ".key")))
|
||||||
|
assert.False(t, m.IsFile(userPath(userID, ".rev")))
|
||||||
|
assert.False(t, m.IsFile(userPath(userID, ".json")))
|
||||||
|
assert.False(t, m.IsFile(userPath(userID, ".lthn")))
|
||||||
|
|
||||||
|
// Session should be gone
|
||||||
|
a.mu.RLock()
|
||||||
|
sessionCount := 0
|
||||||
|
for _, s := range a.sessions {
|
||||||
|
if s.UserID == userID {
|
||||||
|
sessionCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.mu.RUnlock()
|
||||||
|
assert.Equal(t, 0, sessionCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteUser_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Protected user "server" cannot be deleted
|
||||||
|
err := a.DeleteUser("server")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "cannot delete protected user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteUser_Ugly(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Non-existent user
|
||||||
|
err := a.DeleteUser("nonexistent-user-id")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Login ---
|
||||||
|
|
||||||
|
func TestLogin_Good(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.Register("mallory", "secret")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("mallory")
|
||||||
|
|
||||||
|
session, err := a.Login(userID, "secret")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, session)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, session.Token)
|
||||||
|
assert.Equal(t, userID, session.UserID)
|
||||||
|
assert.True(t, session.ExpiresAt.After(time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.Register("nancy", "correct-password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("nancy")
|
||||||
|
|
||||||
|
// Wrong password
|
||||||
|
_, err = a.Login(userID, "wrong-password")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid password")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_Ugly(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Login for non-existent user
|
||||||
|
_, err := a.Login("nonexistent-user-id", "pass")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WriteChallengeFile / ReadResponseFile (Air-Gapped) ---
|
||||||
|
|
||||||
|
func TestAirGappedFlow_Good(t *testing.T) {
|
||||||
|
a, m := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.Register("oscar", "airgap-pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("oscar")
|
||||||
|
|
||||||
|
// Write challenge to file
|
||||||
|
challengePath := "transfer/challenge.json"
|
||||||
|
err = a.WriteChallengeFile(userID, challengePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, m.IsFile(challengePath))
|
||||||
|
|
||||||
|
// Read challenge file to get the encrypted nonce (simulating courier)
|
||||||
|
challengeData, err := m.Read(challengePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var challenge Challenge
|
||||||
|
err = json.Unmarshal([]byte(challengeData), &challenge)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Client-side: decrypt nonce and sign it
|
||||||
|
privKey, err := m.Read(userPath(userID, ".key"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "airgap-pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
signedNonce, err := pgp.Sign(decryptedNonce, privKey, "airgap-pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Write signed response to file
|
||||||
|
responsePath := "transfer/response.sig"
|
||||||
|
err = m.Write(responsePath, string(signedNonce))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Server reads response file
|
||||||
|
session, err := a.ReadResponseFile(userID, responsePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, session)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, session.Token)
|
||||||
|
assert.Equal(t, userID, session.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteChallengeFile_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Challenge for non-existent user
|
||||||
|
err := a.WriteChallengeFile("nonexistent-user", "challenge.json")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadResponseFile_Bad(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
// Response file does not exist
|
||||||
|
_, err := a.ReadResponseFile("some-user", "nonexistent-file.sig")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadResponseFile_Ugly(t *testing.T) {
|
||||||
|
a, m := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.Register("peggy", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("peggy")
|
||||||
|
|
||||||
|
// Create a challenge
|
||||||
|
_, err = a.CreateChallenge(userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Write garbage to response file
|
||||||
|
responsePath := "transfer/bad-response.sig"
|
||||||
|
err = m.Write(responsePath, "not-a-valid-signature")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = a.ReadResponseFile(userID, responsePath)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Options ---
|
||||||
|
|
||||||
|
func TestWithChallengeTTL_Good(t *testing.T) {
|
||||||
|
ttl := 30 * time.Second
|
||||||
|
a, _ := newTestAuth(WithChallengeTTL(ttl))
|
||||||
|
assert.Equal(t, ttl, a.challengeTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithSessionTTL_Good(t *testing.T) {
|
||||||
|
ttl := 2 * time.Hour
|
||||||
|
a, _ := newTestAuth(WithSessionTTL(ttl))
|
||||||
|
assert.Equal(t, ttl, a.sessionTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Full Round-Trip (Online Flow) ---
|
||||||
|
|
||||||
|
func TestFullRoundTrip_Good(t *testing.T) {
|
||||||
|
a, m := newTestAuth()
|
||||||
|
|
||||||
|
// 1. Register
|
||||||
|
user, err := a.Register("quinn", "roundtrip-pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, user)
|
||||||
|
|
||||||
|
userID := lthn.Hash("quinn")
|
||||||
|
|
||||||
|
// 2. Create challenge
|
||||||
|
challenge, err := a.CreateChallenge(userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 3. Client decrypts + signs
|
||||||
|
privKey, err := m.Read(userPath(userID, ".key"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
nonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "roundtrip-pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sig, err := pgp.Sign(nonce, privKey, "roundtrip-pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 4. Server validates, issues session
|
||||||
|
session, err := a.ValidateResponse(userID, sig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, session)
|
||||||
|
|
||||||
|
// 5. Validate session
|
||||||
|
validated, err := a.ValidateSession(session.Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, session.Token, validated.Token)
|
||||||
|
|
||||||
|
// 6. Refresh session
|
||||||
|
refreshed, err := a.RefreshSession(session.Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, session.Token, refreshed.Token)
|
||||||
|
|
||||||
|
// 7. Revoke session
|
||||||
|
err = a.RevokeSession(session.Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 8. Session should be invalid now
|
||||||
|
_, err = a.ValidateSession(session.Token)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Concurrent Access ---
|
||||||
|
|
||||||
|
func TestConcurrentSessions_Good(t *testing.T) {
|
||||||
|
a, _ := newTestAuth()
|
||||||
|
|
||||||
|
_, err := a.Register("ruth", "pass")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID := lthn.Hash("ruth")
|
||||||
|
|
||||||
|
// Create multiple sessions concurrently
|
||||||
|
const n = 10
|
||||||
|
sessions := make(chan *Session, n)
|
||||||
|
errs := make(chan error, n)
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
go func() {
|
||||||
|
s, err := a.Login(userID, "pass")
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessions <- s
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
select {
|
||||||
|
case s := <-sessions:
|
||||||
|
require.NotNil(t, s)
|
||||||
|
// Validate each session
|
||||||
|
_, err := a.ValidateSession(s.Token)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
case err := <-errs:
|
||||||
|
t.Fatalf("concurrent login failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
pkg/crypt/pgp/pgp.go
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
// Package pgp provides OpenPGP key generation, encryption, decryption,
|
||||||
|
// signing, and verification using the ProtonMail go-crypto library.
|
||||||
|
//
|
||||||
|
// Ported from Enchantrix (github.com/Snider/Enchantrix/pkg/crypt/std/pgp).
|
||||||
|
package pgp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPair holds armored PGP public and private keys.
|
||||||
|
type KeyPair struct {
|
||||||
|
PublicKey string
|
||||||
|
PrivateKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKeyPair generates a new PGP key pair for the given identity.
|
||||||
|
// If password is non-empty, the private key is encrypted with it.
|
||||||
|
// Returns a KeyPair with armored public and private keys.
|
||||||
|
func CreateKeyPair(name, email, password string) (*KeyPair, error) {
|
||||||
|
entity, err := openpgp.NewEntity(name, "", email, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to create entity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign all the identities
|
||||||
|
for _, id := range entity.Identities {
|
||||||
|
_ = id.SelfSignature.SignUserId(id.UserId.Id, entity.PrimaryKey, entity.PrivateKey, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt private key with password if provided
|
||||||
|
if password != "" {
|
||||||
|
err = entity.PrivateKey.Encrypt([]byte(password))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to encrypt private key: %w", err)
|
||||||
|
}
|
||||||
|
for _, subkey := range entity.Subkeys {
|
||||||
|
err = subkey.PrivateKey.Encrypt([]byte(password))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to encrypt subkey: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize public key
|
||||||
|
pubKeyBuf := new(bytes.Buffer)
|
||||||
|
pubKeyWriter, err := armor.Encode(pubKeyBuf, openpgp.PublicKeyType, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to create armored public key writer: %w", err)
|
||||||
|
}
|
||||||
|
if err := entity.Serialize(pubKeyWriter); err != nil {
|
||||||
|
pubKeyWriter.Close()
|
||||||
|
return nil, fmt.Errorf("pgp: failed to serialize public key: %w", err)
|
||||||
|
}
|
||||||
|
pubKeyWriter.Close()
|
||||||
|
|
||||||
|
// Serialize private key
|
||||||
|
privKeyBuf := new(bytes.Buffer)
|
||||||
|
privKeyWriter, err := armor.Encode(privKeyBuf, openpgp.PrivateKeyType, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to create armored private key writer: %w", err)
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
// Manual serialization to avoid re-signing encrypted keys
|
||||||
|
if err := serializeEncryptedEntity(privKeyWriter, entity); err != nil {
|
||||||
|
privKeyWriter.Close()
|
||||||
|
return nil, fmt.Errorf("pgp: failed to serialize private key: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := entity.SerializePrivate(privKeyWriter, nil); err != nil {
|
||||||
|
privKeyWriter.Close()
|
||||||
|
return nil, fmt.Errorf("pgp: failed to serialize private key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
privKeyWriter.Close()
|
||||||
|
|
||||||
|
return &KeyPair{
|
||||||
|
PublicKey: pubKeyBuf.String(),
|
||||||
|
PrivateKey: privKeyBuf.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeEncryptedEntity manually serializes an entity with encrypted private keys
|
||||||
|
// to avoid the panic from re-signing encrypted keys.
|
||||||
|
func serializeEncryptedEntity(w io.Writer, e *openpgp.Entity) error {
|
||||||
|
if err := e.PrivateKey.Serialize(w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, ident := range e.Identities {
|
||||||
|
if err := ident.UserId.Serialize(w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ident.SelfSignature.Serialize(w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, subkey := range e.Subkeys {
|
||||||
|
if err := subkey.PrivateKey.Serialize(w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := subkey.Sig.Serialize(w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt encrypts data for the recipient identified by their armored public key.
|
||||||
|
// Returns the encrypted data as armored PGP output.
|
||||||
|
func Encrypt(data []byte, publicKeyArmor string) ([]byte, error) {
|
||||||
|
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to read public key ring: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
armoredWriter, err := armor.Encode(buf, "PGP MESSAGE", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to create armor encoder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := openpgp.Encrypt(armoredWriter, keyring, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
armoredWriter.Close()
|
||||||
|
return nil, fmt.Errorf("pgp: failed to create encryption writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
w.Close()
|
||||||
|
armoredWriter.Close()
|
||||||
|
return nil, fmt.Errorf("pgp: failed to write data: %w", err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
armoredWriter.Close()
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts armored PGP data using the given armored private key.
|
||||||
|
// If the private key is encrypted, the password is used to decrypt it first.
|
||||||
|
func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
|
||||||
|
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to read private key ring: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the private key if it is encrypted
|
||||||
|
for _, entity := range keyring {
|
||||||
|
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
|
||||||
|
if err := entity.PrivateKey.Decrypt([]byte(password)); err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to decrypt private key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, subkey := range entity.Subkeys {
|
||||||
|
if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted {
|
||||||
|
_ = subkey.PrivateKey.Decrypt([]byte(password))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode armored message
|
||||||
|
block, err := armor.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to decode armored message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to read message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := io.ReadAll(md.UnverifiedBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to read plaintext: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign creates an armored detached signature for the given data using
|
||||||
|
// the armored private key. If the key is encrypted, the password is used
|
||||||
|
// to decrypt it first.
|
||||||
|
func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) {
|
||||||
|
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to read private key ring: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signer := keyring[0]
|
||||||
|
if signer.PrivateKey == nil {
|
||||||
|
return nil, fmt.Errorf("pgp: private key not found in keyring")
|
||||||
|
}
|
||||||
|
|
||||||
|
if signer.PrivateKey.Encrypted {
|
||||||
|
if err := signer.PrivateKey.Decrypt([]byte(password)); err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to decrypt private key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
config := &packet.Config{}
|
||||||
|
err = openpgp.ArmoredDetachSign(buf, signer, bytes.NewReader(data), config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgp: failed to sign message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify verifies an armored detached signature against the given data
|
||||||
|
// and armored public key. Returns nil if the signature is valid.
|
||||||
|
func Verify(data, signature []byte, publicKeyArmor string) error {
|
||||||
|
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pgp: failed to read public key ring: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(data), bytes.NewReader(signature), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pgp: signature verification failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
164
pkg/crypt/pgp/pgp_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
package pgp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateKeyPair_Good(t *testing.T) {
|
||||||
|
kp, err := CreateKeyPair("Test User", "test@example.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, kp)
|
||||||
|
assert.Contains(t, kp.PublicKey, "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||||
|
assert.Contains(t, kp.PrivateKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateKeyPair_Bad(t *testing.T) {
|
||||||
|
// Empty name still works (openpgp allows it), but test with password
|
||||||
|
kp, err := CreateKeyPair("Secure User", "secure@example.com", "strong-password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, kp)
|
||||||
|
assert.Contains(t, kp.PublicKey, "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||||
|
assert.Contains(t, kp.PrivateKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateKeyPair_Ugly(t *testing.T) {
|
||||||
|
// Minimal identity
|
||||||
|
kp, err := CreateKeyPair("", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, kp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecrypt_Good(t *testing.T) {
|
||||||
|
kp, err := CreateKeyPair("Test User", "test@example.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
plaintext := []byte("hello, OpenPGP!")
|
||||||
|
ciphertext, err := Encrypt(plaintext, kp.PublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, ciphertext)
|
||||||
|
assert.Contains(t, string(ciphertext), "-----BEGIN PGP MESSAGE-----")
|
||||||
|
|
||||||
|
decrypted, err := Decrypt(ciphertext, kp.PrivateKey, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, plaintext, decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecrypt_Bad(t *testing.T) {
|
||||||
|
kp1, err := CreateKeyPair("User One", "one@example.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
kp2, err := CreateKeyPair("User Two", "two@example.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
plaintext := []byte("secret data")
|
||||||
|
ciphertext, err := Encrypt(plaintext, kp1.PublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Decrypting with wrong key should fail
|
||||||
|
_, err = Decrypt(ciphertext, kp2.PrivateKey, "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecrypt_Ugly(t *testing.T) {
|
||||||
|
// Invalid public key for encryption
|
||||||
|
_, err := Encrypt([]byte("data"), "not-a-pgp-key")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Invalid private key for decryption
|
||||||
|
_, err = Decrypt([]byte("data"), "not-a-pgp-key", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecryptWithPassword_Good(t *testing.T) {
|
||||||
|
password := "my-secret-passphrase"
|
||||||
|
kp, err := CreateKeyPair("Secure User", "secure@example.com", password)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
plaintext := []byte("encrypted with password-protected key")
|
||||||
|
ciphertext, err := Encrypt(plaintext, kp.PublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decrypted, err := Decrypt(ciphertext, kp.PrivateKey, password)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, plaintext, decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignVerify_Good(t *testing.T) {
|
||||||
|
kp, err := CreateKeyPair("Signer", "signer@example.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data := []byte("message to sign")
|
||||||
|
signature, err := Sign(data, kp.PrivateKey, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, signature)
|
||||||
|
assert.Contains(t, string(signature), "-----BEGIN PGP SIGNATURE-----")
|
||||||
|
|
||||||
|
err = Verify(data, signature, kp.PublicKey)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignVerify_Bad(t *testing.T) {
|
||||||
|
kp, err := CreateKeyPair("Signer", "signer@example.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data := []byte("original message")
|
||||||
|
signature, err := Sign(data, kp.PrivateKey, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify with tampered data should fail
|
||||||
|
err = Verify([]byte("tampered message"), signature, kp.PublicKey)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignVerify_Ugly(t *testing.T) {
|
||||||
|
// Invalid key for signing
|
||||||
|
_, err := Sign([]byte("data"), "not-a-key", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Invalid key for verification
|
||||||
|
kp, err := CreateKeyPair("Signer", "signer@example.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data := []byte("message")
|
||||||
|
sig, err := Sign(data, kp.PrivateKey, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = Verify(data, sig, "not-a-key")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignVerifyWithPassword_Good(t *testing.T) {
|
||||||
|
password := "signing-password"
|
||||||
|
kp, err := CreateKeyPair("Signer", "signer@example.com", password)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data := []byte("signed with password-protected key")
|
||||||
|
signature, err := Sign(data, kp.PrivateKey, password)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = Verify(data, signature, kp.PublicKey)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullRoundTrip_Good(t *testing.T) {
|
||||||
|
// Generate keys, encrypt, decrypt, sign, and verify - full round trip
|
||||||
|
kp, err := CreateKeyPair("Full Test", "full@example.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
original := []byte("full round-trip test data")
|
||||||
|
|
||||||
|
// Encrypt then decrypt
|
||||||
|
ciphertext, err := Encrypt(original, kp.PublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
decrypted, err := Decrypt(ciphertext, kp.PrivateKey, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, decrypted)
|
||||||
|
|
||||||
|
// Sign then verify
|
||||||
|
signature, err := Sign(original, kp.PrivateKey, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = Verify(original, signature, kp.PublicKey)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
543
pkg/io/node/node_test.go
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
package node
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// New
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNew_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
require.NotNil(t, n, "New() must not return nil")
|
||||||
|
assert.NotNil(t, n.files, "New() must initialize the files map")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AddData
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestAddData_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
|
||||||
|
file, ok := n.files["foo.txt"]
|
||||||
|
require.True(t, ok, "file foo.txt should be present")
|
||||||
|
assert.Equal(t, []byte("foo"), file.content)
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "foo.txt", info.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddData_Bad(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
|
||||||
|
// Empty name is silently ignored.
|
||||||
|
n.AddData("", []byte("data"))
|
||||||
|
assert.Empty(t, n.files, "empty name must not be stored")
|
||||||
|
|
||||||
|
// Directory entry (trailing slash) is silently ignored.
|
||||||
|
n.AddData("dir/", nil)
|
||||||
|
assert.Empty(t, n.files, "directory entry must not be stored")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddData_Ugly(t *testing.T) {
|
||||||
|
t.Run("Overwrite", func(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
n.AddData("foo.txt", []byte("bar"))
|
||||||
|
|
||||||
|
file := n.files["foo.txt"]
|
||||||
|
assert.Equal(t, []byte("bar"), file.content, "second AddData should overwrite")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("LeadingSlash", func(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("/hello.txt", []byte("hi"))
|
||||||
|
_, ok := n.files["hello.txt"]
|
||||||
|
assert.True(t, ok, "leading slash should be trimmed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Open
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestOpen_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
|
||||||
|
file, err := n.Open("foo.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 10)
|
||||||
|
nr, err := file.Read(buf)
|
||||||
|
require.True(t, nr > 0 || err == io.EOF)
|
||||||
|
assert.Equal(t, "foo", string(buf[:nr]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_Bad(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
_, err := n.Open("nonexistent.txt")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_Ugly(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("bar/baz.txt", []byte("baz"))
|
||||||
|
|
||||||
|
// Opening a directory should succeed.
|
||||||
|
file, err := n.Open("bar")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Reading from a directory should fail.
|
||||||
|
_, err = file.Read(make([]byte, 1))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
var pathErr *fs.PathError
|
||||||
|
require.True(t, errors.As(err, &pathErr))
|
||||||
|
assert.Equal(t, fs.ErrInvalid, pathErr.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stat
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestStat_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
n.AddData("bar/baz.txt", []byte("baz"))
|
||||||
|
|
||||||
|
// File stat.
|
||||||
|
info, err := n.Stat("bar/baz.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "baz.txt", info.Name())
|
||||||
|
assert.Equal(t, int64(3), info.Size())
|
||||||
|
assert.False(t, info.IsDir())
|
||||||
|
|
||||||
|
// Directory stat.
|
||||||
|
dirInfo, err := n.Stat("bar")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, dirInfo.IsDir())
|
||||||
|
assert.Equal(t, "bar", dirInfo.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStat_Bad(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
_, err := n.Stat("nonexistent")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStat_Ugly(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
|
||||||
|
// Root directory.
|
||||||
|
info, err := n.Stat(".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, info.IsDir())
|
||||||
|
assert.Equal(t, ".", info.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ReadFile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestReadFile_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("hello.txt", []byte("hello world"))
|
||||||
|
|
||||||
|
data, err := n.ReadFile("hello.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("hello world"), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFile_Bad(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
_, err := n.ReadFile("missing.txt")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFile_Ugly(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("data.bin", []byte("original"))
|
||||||
|
|
||||||
|
// Returned slice must be a copy — mutating it must not affect internal state.
|
||||||
|
data, err := n.ReadFile("data.bin")
|
||||||
|
require.NoError(t, err)
|
||||||
|
data[0] = 'X'
|
||||||
|
|
||||||
|
data2, err := n.ReadFile("data.bin")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("original"), data2, "ReadFile must return an independent copy")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ReadDir
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestReadDir_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
n.AddData("bar/baz.txt", []byte("baz"))
|
||||||
|
n.AddData("bar/qux.txt", []byte("qux"))
|
||||||
|
|
||||||
|
// Root.
|
||||||
|
entries, err := n.ReadDir(".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"bar", "foo.txt"}, sortedNames(entries))
|
||||||
|
|
||||||
|
// Subdirectory.
|
||||||
|
barEntries, err := n.ReadDir("bar")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"baz.txt", "qux.txt"}, sortedNames(barEntries))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadDir_Bad(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
|
||||||
|
// Reading a file as a directory should fail.
|
||||||
|
_, err := n.ReadDir("foo.txt")
|
||||||
|
require.Error(t, err)
|
||||||
|
var pathErr *fs.PathError
|
||||||
|
require.True(t, errors.As(err, &pathErr))
|
||||||
|
assert.Equal(t, fs.ErrInvalid, pathErr.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadDir_Ugly(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("bar/baz.txt", []byte("baz"))
|
||||||
|
n.AddData("empty_dir/", nil) // Ignored by AddData.
|
||||||
|
|
||||||
|
entries, err := n.ReadDir(".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"bar"}, sortedNames(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exists
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestExists_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
n.AddData("bar/baz.txt", []byte("baz"))
|
||||||
|
|
||||||
|
exists, err := n.Exists("foo.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists)
|
||||||
|
|
||||||
|
exists, err = n.Exists("bar")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExists_Bad(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
exists, err := n.Exists("nonexistent")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExists_Ugly(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("dummy.txt", []byte("dummy"))
|
||||||
|
|
||||||
|
exists, err := n.Exists(".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "root '.' must exist")
|
||||||
|
|
||||||
|
exists, err = n.Exists("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "empty path (root) must exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Walk
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestWalk_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
n.AddData("bar/baz.txt", []byte("baz"))
|
||||||
|
n.AddData("bar/qux.txt", []byte("qux"))
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
err := n.Walk(".", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
paths = append(paths, p)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sort.Strings(paths)
|
||||||
|
assert.Equal(t, []string{".", "bar", "bar/baz.txt", "bar/qux.txt", "foo.txt"}, paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalk_Bad(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
err := n.Walk("nonexistent", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
called = true
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
assert.True(t, called, "walk function must be called for nonexistent root")
|
||||||
|
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalk_Ugly(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("a/b.txt", []byte("b"))
|
||||||
|
n.AddData("a/c.txt", []byte("c"))
|
||||||
|
|
||||||
|
// Stop walk early with a custom error.
|
||||||
|
walkErr := errors.New("stop walking")
|
||||||
|
var paths []string
|
||||||
|
err := n.Walk(".", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if p == "a/b.txt" {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
paths = append(paths, p)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, walkErr, err, "Walk must propagate the callback error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalk_Options(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("root.txt", []byte("root"))
|
||||||
|
n.AddData("a/a1.txt", []byte("a1"))
|
||||||
|
n.AddData("a/b/b1.txt", []byte("b1"))
|
||||||
|
n.AddData("c/c1.txt", []byte("c1"))
|
||||||
|
|
||||||
|
t.Run("MaxDepth", func(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
err := n.Walk(".", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
paths = append(paths, p)
|
||||||
|
return nil
|
||||||
|
}, WalkOptions{MaxDepth: 1})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sort.Strings(paths)
|
||||||
|
assert.Equal(t, []string{".", "a", "c", "root.txt"}, paths)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Filter", func(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
err := n.Walk(".", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
paths = append(paths, p)
|
||||||
|
return nil
|
||||||
|
}, WalkOptions{Filter: func(p string, d fs.DirEntry) bool {
|
||||||
|
return !strings.HasPrefix(p, "a")
|
||||||
|
}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sort.Strings(paths)
|
||||||
|
assert.Equal(t, []string{".", "c", "c/c1.txt", "root.txt"}, paths)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SkipErrors", func(t *testing.T) {
|
||||||
|
var called bool
|
||||||
|
err := n.Walk("nonexistent", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
called = true
|
||||||
|
return err
|
||||||
|
}, WalkOptions{SkipErrors: true})
|
||||||
|
|
||||||
|
assert.NoError(t, err, "SkipErrors should suppress the error")
|
||||||
|
assert.False(t, called, "callback should not be called when error is skipped")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CopyFile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCopyFile_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
|
||||||
|
tmpfile := filepath.Join(t.TempDir(), "test.txt")
|
||||||
|
err := n.CopyFile("foo.txt", tmpfile, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(tmpfile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "foo", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyFile_Bad(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
tmpfile := filepath.Join(t.TempDir(), "test.txt")
|
||||||
|
|
||||||
|
// Source does not exist.
|
||||||
|
err := n.CopyFile("nonexistent.txt", tmpfile, 0644)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Destination not writable.
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
err = n.CopyFile("foo.txt", "/nonexistent_dir/test.txt", 0644)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyFile_Ugly(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("bar/baz.txt", []byte("baz"))
|
||||||
|
tmpfile := filepath.Join(t.TempDir(), "test.txt")
|
||||||
|
|
||||||
|
// Attempting to copy a directory should fail.
|
||||||
|
err := n.CopyFile("bar", tmpfile, 0644)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ToTar / FromTar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestToTar_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("foo.txt", []byte("foo"))
|
||||||
|
n.AddData("bar/baz.txt", []byte("baz"))
|
||||||
|
|
||||||
|
tarball, err := n.ToTar()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, tarball)
|
||||||
|
|
||||||
|
// Verify tar content.
|
||||||
|
tr := tar.NewReader(bytes.NewReader(tarball))
|
||||||
|
files := make(map[string]string)
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
content, err := io.ReadAll(tr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
files[header.Name] = string(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "foo", files["foo.txt"])
|
||||||
|
assert.Equal(t, "baz", files["bar/baz.txt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromTar_Good(t *testing.T) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
tw := tar.NewWriter(buf)
|
||||||
|
|
||||||
|
for _, f := range []struct{ Name, Body string }{
|
||||||
|
{"foo.txt", "foo"},
|
||||||
|
{"bar/baz.txt", "baz"},
|
||||||
|
} {
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Name: f.Name,
|
||||||
|
Mode: 0600,
|
||||||
|
Size: int64(len(f.Body)),
|
||||||
|
Typeflag: tar.TypeReg,
|
||||||
|
}
|
||||||
|
require.NoError(t, tw.WriteHeader(hdr))
|
||||||
|
_, err := tw.Write([]byte(f.Body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
require.NoError(t, tw.Close())
|
||||||
|
|
||||||
|
n, err := FromTar(buf.Bytes())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
exists, _ := n.Exists("foo.txt")
|
||||||
|
assert.True(t, exists, "foo.txt should exist")
|
||||||
|
|
||||||
|
exists, _ = n.Exists("bar/baz.txt")
|
||||||
|
assert.True(t, exists, "bar/baz.txt should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromTar_Bad(t *testing.T) {
|
||||||
|
// Truncated data that cannot be a valid tar.
|
||||||
|
truncated := make([]byte, 100)
|
||||||
|
_, err := FromTar(truncated)
|
||||||
|
assert.Error(t, err, "truncated data should produce an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarRoundTrip_Good(t *testing.T) {
|
||||||
|
n1 := New()
|
||||||
|
n1.AddData("a.txt", []byte("alpha"))
|
||||||
|
n1.AddData("b/c.txt", []byte("charlie"))
|
||||||
|
|
||||||
|
tarball, err := n1.ToTar()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
n2, err := FromTar(tarball)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify n2 matches n1.
|
||||||
|
data, err := n2.ReadFile("a.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("alpha"), data)
|
||||||
|
|
||||||
|
data, err = n2.ReadFile("b/c.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("charlie"), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fs.FS interface compliance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestFSInterface_Good(t *testing.T) {
|
||||||
|
n := New()
|
||||||
|
n.AddData("hello.txt", []byte("world"))
|
||||||
|
|
||||||
|
// fs.FS
|
||||||
|
var fsys fs.FS = n
|
||||||
|
file, err := fsys.Open("hello.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// fs.StatFS
|
||||||
|
var statFS fs.StatFS = n
|
||||||
|
info, err := statFS.Stat("hello.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello.txt", info.Name())
|
||||||
|
assert.Equal(t, int64(5), info.Size())
|
||||||
|
|
||||||
|
// fs.ReadFileFS
|
||||||
|
var readFS fs.ReadFileFS = n
|
||||||
|
data, err := readFS.ReadFile("hello.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("world"), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func sortedNames(entries []fs.DirEntry) []string {
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
625
pkg/io/s3/s3.go
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
// Package s3 provides an S3-backed implementation of the io.Medium interface.
|
||||||
|
package s3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
goio "io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
|
||||||
|
coreerr "github.com/host-uk/core/pkg/framework/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// s3API is the subset of the S3 client API used by this package.
|
||||||
|
// This allows for interface-based mocking in tests.
|
||||||
|
type s3API interface {
|
||||||
|
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
|
||||||
|
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
|
||||||
|
DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error)
|
||||||
|
DeleteObjects(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error)
|
||||||
|
HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
|
||||||
|
ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
|
||||||
|
CopyObject(ctx context.Context, params *s3.CopyObjectInput, optFns ...func(*s3.Options)) (*s3.CopyObjectOutput, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medium is an S3-backed storage backend implementing the io.Medium interface.
|
||||||
|
type Medium struct {
|
||||||
|
client s3API
|
||||||
|
bucket string
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures a Medium.
|
||||||
|
type Option func(*Medium)
|
||||||
|
|
||||||
|
// WithPrefix sets an optional key prefix for all operations.
|
||||||
|
func WithPrefix(prefix string) Option {
|
||||||
|
return func(m *Medium) {
|
||||||
|
// Ensure prefix ends with "/" if non-empty
|
||||||
|
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
m.prefix = prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithClient sets the S3 client for dependency injection.
|
||||||
|
func WithClient(client *s3.Client) Option {
|
||||||
|
return func(m *Medium) {
|
||||||
|
m.client = client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAPI sets the s3API interface directly (for testing with mocks).
|
||||||
|
func withAPI(api s3API) Option {
|
||||||
|
return func(m *Medium) {
|
||||||
|
m.client = api
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new S3 Medium for the given bucket.
|
||||||
|
func New(bucket string, opts ...Option) (*Medium, error) {
|
||||||
|
if bucket == "" {
|
||||||
|
return nil, coreerr.E("s3.New", "bucket name is required", nil)
|
||||||
|
}
|
||||||
|
m := &Medium{bucket: bucket}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
if m.client == nil {
|
||||||
|
return nil, coreerr.E("s3.New", "S3 client is required (use WithClient option)", nil)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// key returns the full S3 object key for a given path.
|
||||||
|
func (m *Medium) key(p string) string {
|
||||||
|
// Clean the path using a leading "/" to sandbox traversal attempts,
|
||||||
|
// then strip the "/" prefix. This ensures ".." can't escape.
|
||||||
|
clean := path.Clean("/" + p)
|
||||||
|
if clean == "/" {
|
||||||
|
clean = ""
|
||||||
|
}
|
||||||
|
clean = strings.TrimPrefix(clean, "/")
|
||||||
|
|
||||||
|
if m.prefix == "" {
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
if clean == "" {
|
||||||
|
return m.prefix
|
||||||
|
}
|
||||||
|
return m.prefix + clean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read retrieves the content of a file as a string.
|
||||||
|
func (m *Medium) Read(p string) (string, error) {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return "", coreerr.E("s3.Read", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := m.client.GetObject(context.Background(), &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", coreerr.E("s3.Read", "failed to get object: "+key, err)
|
||||||
|
}
|
||||||
|
defer out.Body.Close()
|
||||||
|
|
||||||
|
data, err := goio.ReadAll(out.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", coreerr.E("s3.Read", "failed to read body: "+key, err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write saves the given content to a file, overwriting it if it exists.
|
||||||
|
func (m *Medium) Write(p, content string) error {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return coreerr.E("s3.Write", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := m.client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
Body: strings.NewReader(content),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("s3.Write", "failed to put object: "+key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDir is a no-op for S3 (S3 has no real directories).
|
||||||
|
func (m *Medium) EnsureDir(_ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFile checks if a path exists and is a regular file (not a "directory" prefix).
|
||||||
|
func (m *Medium) IsFile(p string) bool {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A "file" in S3 is an object whose key does not end with "/"
|
||||||
|
if strings.HasSuffix(key, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := m.client.HeadObject(context.Background(), &s3.HeadObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileGet is a convenience function that reads a file from the medium.
|
||||||
|
func (m *Medium) FileGet(p string) (string, error) {
|
||||||
|
return m.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSet is a convenience function that writes a file to the medium.
|
||||||
|
func (m *Medium) FileSet(p, content string) error {
|
||||||
|
return m.Write(p, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a single object.
|
||||||
|
func (m *Medium) Delete(p string) error {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return coreerr.E("s3.Delete", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := m.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("s3.Delete", "failed to delete object: "+key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAll removes all objects under the given prefix.
|
||||||
|
func (m *Medium) DeleteAll(p string) error {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return coreerr.E("s3.DeleteAll", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, try deleting the exact key
|
||||||
|
_, _ = m.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Then delete all objects under the prefix
|
||||||
|
prefix := key
|
||||||
|
if !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
paginator := true
|
||||||
|
var continuationToken *string
|
||||||
|
|
||||||
|
for paginator {
|
||||||
|
listOut, err := m.client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
ContinuationToken: continuationToken,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("s3.DeleteAll", "failed to list objects: "+prefix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listOut.Contents) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
objects := make([]types.ObjectIdentifier, len(listOut.Contents))
|
||||||
|
for i, obj := range listOut.Contents {
|
||||||
|
objects[i] = types.ObjectIdentifier{Key: obj.Key}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = m.client.DeleteObjects(context.Background(), &s3.DeleteObjectsInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("s3.DeleteAll", "failed to delete objects", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if listOut.IsTruncated != nil && *listOut.IsTruncated {
|
||||||
|
continuationToken = listOut.NextContinuationToken
|
||||||
|
} else {
|
||||||
|
paginator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename moves an object by copying then deleting the original.
|
||||||
|
func (m *Medium) Rename(oldPath, newPath string) error {
|
||||||
|
oldKey := m.key(oldPath)
|
||||||
|
newKey := m.key(newPath)
|
||||||
|
if oldKey == "" || newKey == "" {
|
||||||
|
return coreerr.E("s3.Rename", "both old and new paths are required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
copySource := m.bucket + "/" + oldKey
|
||||||
|
|
||||||
|
_, err := m.client.CopyObject(context.Background(), &s3.CopyObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
CopySource: aws.String(copySource),
|
||||||
|
Key: aws.String(newKey),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("s3.Rename", "failed to copy object: "+oldKey+" -> "+newKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = m.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(oldKey),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("s3.Rename", "failed to delete source object: "+oldKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns directory entries for the given path using ListObjectsV2 with delimiter.
|
||||||
|
func (m *Medium) List(p string) ([]fs.DirEntry, error) {
|
||||||
|
prefix := m.key(p)
|
||||||
|
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []fs.DirEntry
|
||||||
|
|
||||||
|
listOut, err := m.client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
Delimiter: aws.String("/"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("s3.List", "failed to list objects: "+prefix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common prefixes are "directories"
|
||||||
|
for _, cp := range listOut.CommonPrefixes {
|
||||||
|
if cp.Prefix == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(*cp.Prefix, prefix)
|
||||||
|
name = strings.TrimSuffix(name, "/")
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, &dirEntry{
|
||||||
|
name: name,
|
||||||
|
isDir: true,
|
||||||
|
mode: fs.ModeDir | 0755,
|
||||||
|
info: &fileInfo{
|
||||||
|
name: name,
|
||||||
|
isDir: true,
|
||||||
|
mode: fs.ModeDir | 0755,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contents are "files" (excluding the prefix itself)
|
||||||
|
for _, obj := range listOut.Contents {
|
||||||
|
if obj.Key == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(*obj.Key, prefix)
|
||||||
|
if name == "" || strings.Contains(name, "/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
if obj.Size != nil {
|
||||||
|
size = *obj.Size
|
||||||
|
}
|
||||||
|
var modTime time.Time
|
||||||
|
if obj.LastModified != nil {
|
||||||
|
modTime = *obj.LastModified
|
||||||
|
}
|
||||||
|
entries = append(entries, &dirEntry{
|
||||||
|
name: name,
|
||||||
|
isDir: false,
|
||||||
|
mode: 0644,
|
||||||
|
info: &fileInfo{
|
||||||
|
name: name,
|
||||||
|
size: size,
|
||||||
|
mode: 0644,
|
||||||
|
modTime: modTime,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file information for the given path using HeadObject.
|
||||||
|
func (m *Medium) Stat(p string) (fs.FileInfo, error) {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("s3.Stat", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := m.client.HeadObject(context.Background(), &s3.HeadObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("s3.Stat", "failed to head object: "+key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var size int64
|
||||||
|
if out.ContentLength != nil {
|
||||||
|
size = *out.ContentLength
|
||||||
|
}
|
||||||
|
var modTime time.Time
|
||||||
|
if out.LastModified != nil {
|
||||||
|
modTime = *out.LastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
name := path.Base(key)
|
||||||
|
return &fileInfo{
|
||||||
|
name: name,
|
||||||
|
size: size,
|
||||||
|
mode: 0644,
|
||||||
|
modTime: modTime,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens the named file for reading.
|
||||||
|
func (m *Medium) Open(p string) (fs.File, error) {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("s3.Open", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := m.client.GetObject(context.Background(), &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("s3.Open", "failed to get object: "+key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := goio.ReadAll(out.Body)
|
||||||
|
out.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("s3.Open", "failed to read body: "+key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var size int64
|
||||||
|
if out.ContentLength != nil {
|
||||||
|
size = *out.ContentLength
|
||||||
|
}
|
||||||
|
var modTime time.Time
|
||||||
|
if out.LastModified != nil {
|
||||||
|
modTime = *out.LastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
return &s3File{
|
||||||
|
name: path.Base(key),
|
||||||
|
content: data,
|
||||||
|
size: size,
|
||||||
|
modTime: modTime,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates or truncates the named file. Returns a writer that
|
||||||
|
// uploads the content on Close.
|
||||||
|
func (m *Medium) Create(p string) (goio.WriteCloser, error) {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("s3.Create", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
return &s3WriteCloser{
|
||||||
|
medium: m,
|
||||||
|
key: key,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append opens the named file for appending. It downloads the existing
|
||||||
|
// content (if any) and re-uploads the combined content on Close.
|
||||||
|
func (m *Medium) Append(p string) (goio.WriteCloser, error) {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("s3.Append", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing []byte
|
||||||
|
out, err := m.client.GetObject(context.Background(), &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
existing, _ = goio.ReadAll(out.Body)
|
||||||
|
out.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &s3WriteCloser{
|
||||||
|
medium: m,
|
||||||
|
key: key,
|
||||||
|
data: existing,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadStream returns a reader for the file content.
|
||||||
|
func (m *Medium) ReadStream(p string) (goio.ReadCloser, error) {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("s3.ReadStream", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := m.client.GetObject(context.Background(), &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("s3.ReadStream", "failed to get object: "+key, err)
|
||||||
|
}
|
||||||
|
return out.Body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteStream returns a writer for the file content. Content is uploaded on Close.
|
||||||
|
func (m *Medium) WriteStream(p string) (goio.WriteCloser, error) {
|
||||||
|
return m.Create(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a path exists (file or directory prefix).
|
||||||
|
func (m *Medium) Exists(p string) bool {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check as an exact object
|
||||||
|
_, err := m.client.HeadObject(context.Background(), &s3.HeadObjectInput{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check as a "directory" prefix
|
||||||
|
prefix := key
|
||||||
|
if !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
listOut, err := m.client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
MaxKeys: aws.Int32(1),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(listOut.Contents) > 0 || len(listOut.CommonPrefixes) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDir checks if a path exists and is a directory (has objects under it as a prefix).
|
||||||
|
func (m *Medium) IsDir(p string) bool {
|
||||||
|
key := m.key(p)
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := key
|
||||||
|
if !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
listOut, err := m.client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(m.bucket),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
MaxKeys: aws.Int32(1),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(listOut.Contents) > 0 || len(listOut.CommonPrefixes) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal types ---
|
||||||
|
|
||||||
|
// fileInfo implements fs.FileInfo for S3 objects.
|
||||||
|
type fileInfo struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
mode fs.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
isDir bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi *fileInfo) Name() string { return fi.name }
|
||||||
|
func (fi *fileInfo) Size() int64 { return fi.size }
|
||||||
|
func (fi *fileInfo) Mode() fs.FileMode { return fi.mode }
|
||||||
|
func (fi *fileInfo) ModTime() time.Time { return fi.modTime }
|
||||||
|
func (fi *fileInfo) IsDir() bool { return fi.isDir }
|
||||||
|
func (fi *fileInfo) Sys() any { return nil }
|
||||||
|
|
||||||
|
// dirEntry implements fs.DirEntry for S3 listings.
|
||||||
|
type dirEntry struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
mode fs.FileMode
|
||||||
|
info fs.FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (de *dirEntry) Name() string { return de.name }
|
||||||
|
func (de *dirEntry) IsDir() bool { return de.isDir }
|
||||||
|
func (de *dirEntry) Type() fs.FileMode { return de.mode.Type() }
|
||||||
|
func (de *dirEntry) Info() (fs.FileInfo, error) { return de.info, nil }
|
||||||
|
|
||||||
|
// s3File implements fs.File for S3 objects.
|
||||||
|
type s3File struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
offset int64
|
||||||
|
size int64
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *s3File) Stat() (fs.FileInfo, error) {
|
||||||
|
return &fileInfo{
|
||||||
|
name: f.name,
|
||||||
|
size: int64(len(f.content)),
|
||||||
|
mode: 0644,
|
||||||
|
modTime: f.modTime,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *s3File) Read(b []byte) (int, error) {
|
||||||
|
if f.offset >= int64(len(f.content)) {
|
||||||
|
return 0, goio.EOF
|
||||||
|
}
|
||||||
|
n := copy(b, f.content[f.offset:])
|
||||||
|
f.offset += int64(n)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *s3File) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// s3WriteCloser buffers writes and uploads to S3 on Close.
|
||||||
|
type s3WriteCloser struct {
|
||||||
|
medium *Medium
|
||||||
|
key string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *s3WriteCloser) Write(p []byte) (int, error) {
|
||||||
|
w.data = append(w.data, p...)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *s3WriteCloser) Close() error {
|
||||||
|
_, err := w.medium.client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(w.medium.bucket),
|
||||||
|
Key: aws.String(w.key),
|
||||||
|
Body: bytes.NewReader(w.data),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("s3: failed to upload on close: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
646
pkg/io/s3/s3_test.go
Normal file
|
|
@ -0,0 +1,646 @@
|
||||||
|
package s3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
goio "io"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockS3 is an in-memory mock implementing the s3API interface.
|
||||||
|
type mockS3 struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
objects map[string][]byte
|
||||||
|
mtimes map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockS3() *mockS3 {
|
||||||
|
return &mockS3{
|
||||||
|
objects: make(map[string][]byte),
|
||||||
|
mtimes: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockS3) GetObject(_ context.Context, params *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
key := aws.ToString(params.Key)
|
||||||
|
data, ok := m.objects[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("NoSuchKey: key %q not found", key)
|
||||||
|
}
|
||||||
|
mtime := m.mtimes[key]
|
||||||
|
return &s3.GetObjectOutput{
|
||||||
|
Body: goio.NopCloser(bytes.NewReader(data)),
|
||||||
|
ContentLength: aws.Int64(int64(len(data))),
|
||||||
|
LastModified: &mtime,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockS3) PutObject(_ context.Context, params *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
key := aws.ToString(params.Key)
|
||||||
|
data, err := goio.ReadAll(params.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.objects[key] = data
|
||||||
|
m.mtimes[key] = time.Now()
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockS3) DeleteObject(_ context.Context, params *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
key := aws.ToString(params.Key)
|
||||||
|
delete(m.objects, key)
|
||||||
|
delete(m.mtimes, key)
|
||||||
|
return &s3.DeleteObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockS3) DeleteObjects(_ context.Context, params *s3.DeleteObjectsInput, _ ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, obj := range params.Delete.Objects {
|
||||||
|
key := aws.ToString(obj.Key)
|
||||||
|
delete(m.objects, key)
|
||||||
|
delete(m.mtimes, key)
|
||||||
|
}
|
||||||
|
return &s3.DeleteObjectsOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockS3) HeadObject(_ context.Context, params *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
key := aws.ToString(params.Key)
|
||||||
|
data, ok := m.objects[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("NotFound: key %q not found", key)
|
||||||
|
}
|
||||||
|
mtime := m.mtimes[key]
|
||||||
|
return &s3.HeadObjectOutput{
|
||||||
|
ContentLength: aws.Int64(int64(len(data))),
|
||||||
|
LastModified: &mtime,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockS3) ListObjectsV2(_ context.Context, params *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
prefix := aws.ToString(params.Prefix)
|
||||||
|
delimiter := aws.ToString(params.Delimiter)
|
||||||
|
maxKeys := int32(1000)
|
||||||
|
if params.MaxKeys != nil {
|
||||||
|
maxKeys = *params.MaxKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all matching keys sorted
|
||||||
|
var allKeys []string
|
||||||
|
for k := range m.objects {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
allKeys = append(allKeys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(allKeys)
|
||||||
|
|
||||||
|
var contents []types.Object
|
||||||
|
commonPrefixes := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, k := range allKeys {
|
||||||
|
rest := strings.TrimPrefix(k, prefix)
|
||||||
|
|
||||||
|
if delimiter != "" {
|
||||||
|
if idx := strings.Index(rest, delimiter); idx >= 0 {
|
||||||
|
// This key has a delimiter after the prefix -> common prefix
|
||||||
|
cp := prefix + rest[:idx+len(delimiter)]
|
||||||
|
commonPrefixes[cp] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if int32(len(contents)) >= maxKeys {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
data := m.objects[k]
|
||||||
|
mtime := m.mtimes[k]
|
||||||
|
contents = append(contents, types.Object{
|
||||||
|
Key: aws.String(k),
|
||||||
|
Size: aws.Int64(int64(len(data))),
|
||||||
|
LastModified: &mtime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var cpSlice []types.CommonPrefix
|
||||||
|
// Sort common prefixes for deterministic output
|
||||||
|
var cpKeys []string
|
||||||
|
for cp := range commonPrefixes {
|
||||||
|
cpKeys = append(cpKeys, cp)
|
||||||
|
}
|
||||||
|
sort.Strings(cpKeys)
|
||||||
|
for _, cp := range cpKeys {
|
||||||
|
cpSlice = append(cpSlice, types.CommonPrefix{Prefix: aws.String(cp)})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: contents,
|
||||||
|
CommonPrefixes: cpSlice,
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockS3) CopyObject(_ context.Context, params *s3.CopyObjectInput, _ ...func(*s3.Options)) (*s3.CopyObjectOutput, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// CopySource is "bucket/key"
|
||||||
|
source := aws.ToString(params.CopySource)
|
||||||
|
parts := strings.SplitN(source, "/", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid CopySource: %s", source)
|
||||||
|
}
|
||||||
|
srcKey := parts[1]
|
||||||
|
|
||||||
|
data, ok := m.objects[srcKey]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("NoSuchKey: source key %q not found", srcKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
destKey := aws.ToString(params.Key)
|
||||||
|
m.objects[destKey] = append([]byte{}, data...)
|
||||||
|
m.mtimes[destKey] = time.Now()
|
||||||
|
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper ---
|
||||||
|
|
||||||
|
func newTestMedium(t *testing.T) (*Medium, *mockS3) {
|
||||||
|
t.Helper()
|
||||||
|
mock := newMockS3()
|
||||||
|
m, err := New("test-bucket", withAPI(mock))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return m, mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
func TestNew_Good(t *testing.T) {
|
||||||
|
mock := newMockS3()
|
||||||
|
m, err := New("my-bucket", withAPI(mock))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "my-bucket", m.bucket)
|
||||||
|
assert.Equal(t, "", m.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_Bad_NoBucket(t *testing.T) {
|
||||||
|
_, err := New("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "bucket name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_Bad_NoClient(t *testing.T) {
|
||||||
|
_, err := New("bucket")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "S3 client is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithPrefix_Good(t *testing.T) {
|
||||||
|
mock := newMockS3()
|
||||||
|
m, err := New("bucket", withAPI(mock), WithPrefix("data/"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "data/", m.prefix)
|
||||||
|
|
||||||
|
// Prefix without trailing slash gets one added
|
||||||
|
m2, err := New("bucket", withAPI(mock), WithPrefix("data"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "data/", m2.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadWrite_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Write("hello.txt", "world")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := m.Read("hello.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "world", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadWrite_Bad_NotFound(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Read("nonexistent.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadWrite_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Read("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
err = m.Write("", "content")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadWrite_Good_WithPrefix(t *testing.T) {
|
||||||
|
mock := newMockS3()
|
||||||
|
m, err := New("bucket", withAPI(mock), WithPrefix("pfx"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = m.Write("file.txt", "data")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the key has the prefix
|
||||||
|
_, ok := mock.objects["pfx/file.txt"]
|
||||||
|
assert.True(t, ok, "object should be stored with prefix")
|
||||||
|
|
||||||
|
content, err := m.Read("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "data", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureDir_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
// EnsureDir is a no-op for S3
|
||||||
|
err := m.EnsureDir("any/path")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsFile_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Write("file.txt", "content")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, m.IsFile("file.txt"))
|
||||||
|
assert.False(t, m.IsFile("nonexistent.txt"))
|
||||||
|
assert.False(t, m.IsFile(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileGetFileSet_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.FileSet("key.txt", "value")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
val, err := m.FileGet("key.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "value", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Write("to-delete.txt", "content")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, m.Exists("to-delete.txt"))
|
||||||
|
|
||||||
|
err = m.Delete("to-delete.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, m.IsFile("to-delete.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
err := m.Delete("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAll_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
// Create nested structure
|
||||||
|
require.NoError(t, m.Write("dir/file1.txt", "a"))
|
||||||
|
require.NoError(t, m.Write("dir/sub/file2.txt", "b"))
|
||||||
|
require.NoError(t, m.Write("other.txt", "c"))
|
||||||
|
|
||||||
|
err := m.DeleteAll("dir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, m.IsFile("dir/file1.txt"))
|
||||||
|
assert.False(t, m.IsFile("dir/sub/file2.txt"))
|
||||||
|
assert.True(t, m.IsFile("other.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAll_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
err := m.DeleteAll("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRename_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("old.txt", "content"))
|
||||||
|
assert.True(t, m.IsFile("old.txt"))
|
||||||
|
|
||||||
|
err := m.Rename("old.txt", "new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, m.IsFile("old.txt"))
|
||||||
|
assert.True(t, m.IsFile("new.txt"))
|
||||||
|
|
||||||
|
content, err := m.Read("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "content", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRename_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
err := m.Rename("", "new.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
err = m.Rename("old.txt", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRename_Bad_SourceNotFound(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
err := m.Rename("nonexistent.txt", "new.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("dir/file1.txt", "a"))
|
||||||
|
require.NoError(t, m.Write("dir/file2.txt", "b"))
|
||||||
|
require.NoError(t, m.Write("dir/sub/file3.txt", "c"))
|
||||||
|
|
||||||
|
entries, err := m.List("dir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
names := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
names[e.Name()] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, names["file1.txt"], "should list file1.txt")
|
||||||
|
assert.True(t, names["file2.txt"], "should list file2.txt")
|
||||||
|
assert.True(t, names["sub"], "should list sub directory")
|
||||||
|
assert.Len(t, entries, 3)
|
||||||
|
|
||||||
|
// Check that sub is a directory
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() == "sub" {
|
||||||
|
assert.True(t, e.IsDir())
|
||||||
|
info, err := e.Info()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, info.IsDir())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList_Good_Root(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("root.txt", "content"))
|
||||||
|
require.NoError(t, m.Write("dir/nested.txt", "nested"))
|
||||||
|
|
||||||
|
entries, err := m.List("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
names := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
names[e.Name()] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, names["root.txt"])
|
||||||
|
assert.True(t, names["dir"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStat_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "hello world"))
|
||||||
|
|
||||||
|
info, err := m.Stat("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "file.txt", info.Name())
|
||||||
|
assert.Equal(t, int64(11), info.Size())
|
||||||
|
assert.False(t, info.IsDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStat_Bad_NotFound(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Stat("nonexistent.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStat_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
_, err := m.Stat("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "open me"))
|
||||||
|
|
||||||
|
f, err := m.Open("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
data, err := goio.ReadAll(f.(goio.Reader))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "open me", string(data))
|
||||||
|
|
||||||
|
stat, err := f.Stat()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "file.txt", stat.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_Bad_NotFound(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Open("nonexistent.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
w, err := m.Create("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
n, err := w.Write([]byte("created"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 7, n)
|
||||||
|
|
||||||
|
err = w.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := m.Read("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "created", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppend_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("append.txt", "hello"))
|
||||||
|
|
||||||
|
w, err := m.Append("append.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = w.Write([]byte(" world"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = w.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := m.Read("append.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello world", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppend_Good_NewFile(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
w, err := m.Append("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = w.Write([]byte("fresh"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = w.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := m.Read("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "fresh", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadStream_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("stream.txt", "streaming content"))
|
||||||
|
|
||||||
|
reader, err := m.ReadStream("stream.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
data, err := goio.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "streaming content", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadStream_Bad_NotFound(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
_, err := m.ReadStream("nonexistent.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteStream_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
writer, err := m.WriteStream("output.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = goio.Copy(writer, strings.NewReader("piped data"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = writer.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := m.Read("output.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "piped data", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExists_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
assert.False(t, m.Exists("nonexistent.txt"))
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "content"))
|
||||||
|
assert.True(t, m.Exists("file.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExists_Good_DirectoryPrefix(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("dir/file.txt", "content"))
|
||||||
|
// "dir" should exist as a directory prefix
|
||||||
|
assert.True(t, m.Exists("dir"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDir_Good(t *testing.T) {
|
||||||
|
m, _ := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("dir/file.txt", "content"))
|
||||||
|
|
||||||
|
assert.True(t, m.IsDir("dir"))
|
||||||
|
assert.False(t, m.IsDir("dir/file.txt"))
|
||||||
|
assert.False(t, m.IsDir("nonexistent"))
|
||||||
|
assert.False(t, m.IsDir(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKey_Good(t *testing.T) {
|
||||||
|
mock := newMockS3()
|
||||||
|
|
||||||
|
// No prefix
|
||||||
|
m, _ := New("bucket", withAPI(mock))
|
||||||
|
assert.Equal(t, "file.txt", m.key("file.txt"))
|
||||||
|
assert.Equal(t, "dir/file.txt", m.key("dir/file.txt"))
|
||||||
|
assert.Equal(t, "", m.key(""))
|
||||||
|
assert.Equal(t, "file.txt", m.key("/file.txt"))
|
||||||
|
assert.Equal(t, "file.txt", m.key("../file.txt"))
|
||||||
|
|
||||||
|
// With prefix
|
||||||
|
m2, _ := New("bucket", withAPI(mock), WithPrefix("pfx"))
|
||||||
|
assert.Equal(t, "pfx/file.txt", m2.key("file.txt"))
|
||||||
|
assert.Equal(t, "pfx/dir/file.txt", m2.key("dir/file.txt"))
|
||||||
|
assert.Equal(t, "pfx/", m2.key(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ugly: verify the Medium interface is satisfied at compile time.
|
||||||
|
func TestInterfaceCompliance_Ugly(t *testing.T) {
|
||||||
|
mock := newMockS3()
|
||||||
|
m, err := New("bucket", withAPI(mock))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify all methods exist by calling them in a way that
|
||||||
|
// proves compile-time satisfaction of the interface.
|
||||||
|
var _ interface {
|
||||||
|
Read(string) (string, error)
|
||||||
|
Write(string, string) error
|
||||||
|
EnsureDir(string) error
|
||||||
|
IsFile(string) bool
|
||||||
|
FileGet(string) (string, error)
|
||||||
|
FileSet(string, string) error
|
||||||
|
Delete(string) error
|
||||||
|
DeleteAll(string) error
|
||||||
|
Rename(string, string) error
|
||||||
|
List(string) ([]fs.DirEntry, error)
|
||||||
|
Stat(string) (fs.FileInfo, error)
|
||||||
|
Open(string) (fs.File, error)
|
||||||
|
Create(string) (goio.WriteCloser, error)
|
||||||
|
Append(string) (goio.WriteCloser, error)
|
||||||
|
ReadStream(string) (goio.ReadCloser, error)
|
||||||
|
WriteStream(string) (goio.WriteCloser, error)
|
||||||
|
Exists(string) bool
|
||||||
|
IsDir(string) bool
|
||||||
|
} = m
|
||||||
|
}
|
||||||
422
pkg/io/sigil/sigil_test.go
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
package sigil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ReverseSigil
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestReverseSigil_Good(t *testing.T) {
|
||||||
|
s := &ReverseSigil{}
|
||||||
|
|
||||||
|
out, err := s.In([]byte("hello"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("olleh"), out)
|
||||||
|
|
||||||
|
// Symmetric: Out does the same thing.
|
||||||
|
restored, err := s.Out(out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("hello"), restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverseSigil_Bad(t *testing.T) {
|
||||||
|
s := &ReverseSigil{}
|
||||||
|
|
||||||
|
// Empty input returns empty.
|
||||||
|
out, err := s.In([]byte{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte{}, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverseSigil_Ugly(t *testing.T) {
|
||||||
|
s := &ReverseSigil{}
|
||||||
|
|
||||||
|
// Nil input returns nil.
|
||||||
|
out, err := s.In(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, out)
|
||||||
|
|
||||||
|
out, err = s.Out(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HexSigil
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHexSigil_Good(t *testing.T) {
|
||||||
|
s := &HexSigil{}
|
||||||
|
data := []byte("hello world")
|
||||||
|
|
||||||
|
encoded, err := s.In(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte(hex.EncodeToString(data)), encoded)
|
||||||
|
|
||||||
|
decoded, err := s.Out(encoded)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, data, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHexSigil_Bad(t *testing.T) {
|
||||||
|
s := &HexSigil{}
|
||||||
|
|
||||||
|
// Invalid hex input.
|
||||||
|
_, err := s.Out([]byte("zzzz"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Empty input.
|
||||||
|
out, err := s.In([]byte{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte{}, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHexSigil_Ugly(t *testing.T) {
|
||||||
|
s := &HexSigil{}
|
||||||
|
|
||||||
|
out, err := s.In(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, out)
|
||||||
|
|
||||||
|
out, err = s.Out(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Base64Sigil
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestBase64Sigil_Good(t *testing.T) {
|
||||||
|
s := &Base64Sigil{}
|
||||||
|
data := []byte("composable transforms")
|
||||||
|
|
||||||
|
encoded, err := s.In(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte(base64.StdEncoding.EncodeToString(data)), encoded)
|
||||||
|
|
||||||
|
decoded, err := s.Out(encoded)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, data, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBase64Sigil_Bad(t *testing.T) {
|
||||||
|
s := &Base64Sigil{}
|
||||||
|
|
||||||
|
// Invalid base64 (wrong padding).
|
||||||
|
_, err := s.Out([]byte("!!!"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Empty input.
|
||||||
|
out, err := s.In([]byte{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte{}, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBase64Sigil_Ugly(t *testing.T) {
|
||||||
|
s := &Base64Sigil{}
|
||||||
|
|
||||||
|
out, err := s.In(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, out)
|
||||||
|
|
||||||
|
out, err = s.Out(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GzipSigil
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGzipSigil_Good(t *testing.T) {
|
||||||
|
s := &GzipSigil{}
|
||||||
|
data := []byte("the quick brown fox jumps over the lazy dog")
|
||||||
|
|
||||||
|
compressed, err := s.In(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, data, compressed)
|
||||||
|
|
||||||
|
decompressed, err := s.Out(compressed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, data, decompressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGzipSigil_Bad(t *testing.T) {
|
||||||
|
s := &GzipSigil{}
|
||||||
|
|
||||||
|
// Invalid gzip data.
|
||||||
|
_, err := s.Out([]byte("not gzip"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Empty input compresses to a valid gzip stream.
|
||||||
|
compressed, err := s.In([]byte{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, compressed) // gzip header is always present
|
||||||
|
|
||||||
|
decompressed, err := s.Out(compressed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte{}, decompressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGzipSigil_Ugly(t *testing.T) {
|
||||||
|
s := &GzipSigil{}
|
||||||
|
|
||||||
|
out, err := s.In(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, out)
|
||||||
|
|
||||||
|
out, err = s.Out(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSONSigil
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestJSONSigil_Good(t *testing.T) {
|
||||||
|
s := &JSONSigil{Indent: false}
|
||||||
|
data := []byte(`{ "key" : "value" }`)
|
||||||
|
|
||||||
|
compacted, err := s.In(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte(`{"key":"value"}`), compacted)
|
||||||
|
|
||||||
|
// Out is passthrough.
|
||||||
|
passthrough, err := s.Out(compacted)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, compacted, passthrough)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONSigil_Good_Indent(t *testing.T) {
|
||||||
|
s := &JSONSigil{Indent: true}
|
||||||
|
data := []byte(`{"key":"value"}`)
|
||||||
|
|
||||||
|
indented, err := s.In(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(indented), "\n")
|
||||||
|
assert.Contains(t, string(indented), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONSigil_Bad(t *testing.T) {
|
||||||
|
s := &JSONSigil{Indent: false}
|
||||||
|
|
||||||
|
// Invalid JSON.
|
||||||
|
_, err := s.In([]byte("not json"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONSigil_Ugly(t *testing.T) {
|
||||||
|
s := &JSONSigil{Indent: false}
|
||||||
|
|
||||||
|
// json.Compact on nil/empty will produce an error (invalid JSON).
|
||||||
|
_, err := s.In(nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Out with nil is passthrough.
|
||||||
|
out, err := s.Out(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HashSigil
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHashSigil_Good(t *testing.T) {
|
||||||
|
data := []byte("hash me")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sigilName string
|
||||||
|
size int
|
||||||
|
}{
|
||||||
|
{"md5", "md5", md5.Size},
|
||||||
|
{"sha1", "sha1", sha1.Size},
|
||||||
|
{"sha256", "sha256", sha256.Size},
|
||||||
|
{"sha512", "sha512", sha512.Size},
|
||||||
|
{"sha224", "sha224", sha256.Size224},
|
||||||
|
{"sha384", "sha384", sha512.Size384},
|
||||||
|
{"sha512-224", "sha512-224", 28},
|
||||||
|
{"sha512-256", "sha512-256", 32},
|
||||||
|
{"sha3-224", "sha3-224", 28},
|
||||||
|
{"sha3-256", "sha3-256", 32},
|
||||||
|
{"sha3-384", "sha3-384", 48},
|
||||||
|
{"sha3-512", "sha3-512", 64},
|
||||||
|
{"ripemd160", "ripemd160", 20},
|
||||||
|
{"blake2s-256", "blake2s-256", 32},
|
||||||
|
{"blake2b-256", "blake2b-256", 32},
|
||||||
|
{"blake2b-384", "blake2b-384", 48},
|
||||||
|
{"blake2b-512", "blake2b-512", 64},
|
||||||
|
{"md4", "md4", 16},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s, err := NewSigil(tt.sigilName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hashed, err := s.In(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, hashed, tt.size)
|
||||||
|
|
||||||
|
// Out is passthrough.
|
||||||
|
passthrough, err := s.Out(hashed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, hashed, passthrough)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashSigil_Bad(t *testing.T) {
|
||||||
|
// Unsupported hash constant.
|
||||||
|
s := &HashSigil{Hash: 0}
|
||||||
|
_, err := s.In([]byte("data"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashSigil_Ugly(t *testing.T) {
|
||||||
|
// Hashing empty data should still produce a valid digest.
|
||||||
|
s, err := NewSigil("sha256")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hashed, err := s.In([]byte{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, hashed, sha256.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NewSigil factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNewSigil_Good(t *testing.T) {
|
||||||
|
names := []string{
|
||||||
|
"reverse", "hex", "base64", "gzip", "json", "json-indent",
|
||||||
|
"md4", "md5", "sha1", "sha224", "sha256", "sha384", "sha512",
|
||||||
|
"ripemd160",
|
||||||
|
"sha3-224", "sha3-256", "sha3-384", "sha3-512",
|
||||||
|
"sha512-224", "sha512-256",
|
||||||
|
"blake2s-256", "blake2b-256", "blake2b-384", "blake2b-512",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
s, err := NewSigil(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSigil_Bad(t *testing.T) {
|
||||||
|
_, err := NewSigil("nonexistent")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown sigil name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSigil_Ugly(t *testing.T) {
|
||||||
|
_, err := NewSigil("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transmute / Untransmute
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestTransmute_Good(t *testing.T) {
|
||||||
|
data := []byte("round trip")
|
||||||
|
|
||||||
|
hexSigil, err := NewSigil("hex")
|
||||||
|
require.NoError(t, err)
|
||||||
|
base64Sigil, err := NewSigil("base64")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
chain := []Sigil{hexSigil, base64Sigil}
|
||||||
|
|
||||||
|
encoded, err := Transmute(data, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, data, encoded)
|
||||||
|
|
||||||
|
decoded, err := Untransmute(encoded, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, data, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransmute_Good_MultiSigil(t *testing.T) {
|
||||||
|
data := []byte("multi sigil pipeline test data")
|
||||||
|
|
||||||
|
reverseSigil, err := NewSigil("reverse")
|
||||||
|
require.NoError(t, err)
|
||||||
|
hexSigil, err := NewSigil("hex")
|
||||||
|
require.NoError(t, err)
|
||||||
|
base64Sigil, err := NewSigil("base64")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
chain := []Sigil{reverseSigil, hexSigil, base64Sigil}
|
||||||
|
|
||||||
|
encoded, err := Transmute(data, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decoded, err := Untransmute(encoded, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, data, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransmute_Good_GzipRoundTrip(t *testing.T) {
|
||||||
|
data := []byte("compress then encode then decode then decompress")
|
||||||
|
|
||||||
|
gzipSigil, err := NewSigil("gzip")
|
||||||
|
require.NoError(t, err)
|
||||||
|
hexSigil, err := NewSigil("hex")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
chain := []Sigil{gzipSigil, hexSigil}
|
||||||
|
|
||||||
|
encoded, err := Transmute(data, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decoded, err := Untransmute(encoded, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, data, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransmute_Bad(t *testing.T) {
|
||||||
|
// Transmute with a sigil that will fail: hex decode on non-hex input.
|
||||||
|
hexSigil := &HexSigil{}
|
||||||
|
|
||||||
|
// Calling Out (decode) with invalid input via manual chain.
|
||||||
|
_, err := Untransmute([]byte("not-hex!!"), []Sigil{hexSigil})
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransmute_Ugly(t *testing.T) {
|
||||||
|
// Empty sigil chain is a no-op.
|
||||||
|
data := []byte("unchanged")
|
||||||
|
|
||||||
|
result, err := Transmute(data, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, data, result)
|
||||||
|
|
||||||
|
result, err = Untransmute(data, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, data, result)
|
||||||
|
|
||||||
|
// Nil data through a chain.
|
||||||
|
hexSigil, _ := NewSigil("hex")
|
||||||
|
result, err = Transmute(nil, []Sigil{hexSigil})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
||||||
669
pkg/io/sqlite/sqlite.go
Normal file
|
|
@ -0,0 +1,669 @@
|
||||||
|
// Package sqlite provides a SQLite-backed implementation of the io.Medium interface.
|
||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
goio "io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
coreerr "github.com/host-uk/core/pkg/framework/core"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
||||||
|
)
|
||||||
|
|
||||||
|
// Medium is a SQLite-backed storage backend implementing the io.Medium interface.
|
||||||
|
type Medium struct {
|
||||||
|
db *sql.DB
|
||||||
|
table string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures a Medium.
|
||||||
|
type Option func(*Medium)
|
||||||
|
|
||||||
|
// WithTable sets the table name (default: "files").
|
||||||
|
func WithTable(table string) Option {
|
||||||
|
return func(m *Medium) {
|
||||||
|
m.table = table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new SQLite Medium at the given database path.
|
||||||
|
// Use ":memory:" for an in-memory database.
|
||||||
|
func New(dbPath string, opts ...Option) (*Medium, error) {
|
||||||
|
if dbPath == "" {
|
||||||
|
return nil, coreerr.E("sqlite.New", "database path is required", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Medium{table: "files"}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("sqlite.New", "failed to open database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable WAL mode for better concurrency
|
||||||
|
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, coreerr.E("sqlite.New", "failed to set WAL mode", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the schema
|
||||||
|
createSQL := `CREATE TABLE IF NOT EXISTS ` + m.table + ` (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
content BLOB NOT NULL,
|
||||||
|
mode INTEGER DEFAULT 420,
|
||||||
|
is_dir BOOLEAN DEFAULT FALSE,
|
||||||
|
mtime DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`
|
||||||
|
if _, err := db.Exec(createSQL); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, coreerr.E("sqlite.New", "failed to create table", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.db = db
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying database connection.
|
||||||
|
func (m *Medium) Close() error {
|
||||||
|
if m.db != nil {
|
||||||
|
return m.db.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanPath normalizes a path for consistent storage.
|
||||||
|
// Uses a leading "/" before Clean to sandbox traversal attempts.
|
||||||
|
func cleanPath(p string) string {
|
||||||
|
clean := path.Clean("/" + p)
|
||||||
|
if clean == "/" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(clean, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read retrieves the content of a file as a string.
|
||||||
|
func (m *Medium) Read(p string) (string, error) {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return "", coreerr.E("sqlite.Read", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []byte
|
||||||
|
var isDir bool
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT content, is_dir FROM `+m.table+` WHERE path = ?`, key,
|
||||||
|
).Scan(&content, &isDir)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", coreerr.E("sqlite.Read", "file not found: "+key, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", coreerr.E("sqlite.Read", "query failed: "+key, err)
|
||||||
|
}
|
||||||
|
if isDir {
|
||||||
|
return "", coreerr.E("sqlite.Read", "path is a directory: "+key, os.ErrInvalid)
|
||||||
|
}
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write saves the given content to a file, overwriting it if it exists.
|
||||||
|
func (m *Medium) Write(p, content string) error {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return coreerr.E("sqlite.Write", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := m.db.Exec(
|
||||||
|
`INSERT INTO `+m.table+` (path, content, mode, is_dir, mtime) VALUES (?, ?, 420, FALSE, ?)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET content = excluded.content, is_dir = FALSE, mtime = excluded.mtime`,
|
||||||
|
key, []byte(content), time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Write", "insert failed: "+key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDir makes sure a directory exists, creating it if necessary.
|
||||||
|
func (m *Medium) EnsureDir(p string) error {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
// Root always "exists"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := m.db.Exec(
|
||||||
|
`INSERT INTO `+m.table+` (path, content, mode, is_dir, mtime) VALUES (?, '', 493, TRUE, ?)
|
||||||
|
ON CONFLICT(path) DO NOTHING`,
|
||||||
|
key, time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.EnsureDir", "insert failed: "+key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFile checks if a path exists and is a regular file.
|
||||||
|
func (m *Medium) IsFile(p string) bool {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDir bool
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT is_dir FROM `+m.table+` WHERE path = ?`, key,
|
||||||
|
).Scan(&isDir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !isDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileGet is a convenience function that reads a file from the medium.
|
||||||
|
func (m *Medium) FileGet(p string) (string, error) {
|
||||||
|
return m.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSet is a convenience function that writes a file to the medium.
|
||||||
|
func (m *Medium) FileSet(p, content string) error {
|
||||||
|
return m.Write(p, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a file or empty directory.
|
||||||
|
func (m *Medium) Delete(p string) error {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return coreerr.E("sqlite.Delete", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a directory with children
|
||||||
|
var isDir bool
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT is_dir FROM `+m.table+` WHERE path = ?`, key,
|
||||||
|
).Scan(&isDir)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return coreerr.E("sqlite.Delete", "path not found: "+key, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Delete", "query failed: "+key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDir {
|
||||||
|
// Check for children
|
||||||
|
prefix := key + "/"
|
||||||
|
var count int
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM `+m.table+` WHERE path LIKE ? AND path != ?`, prefix+"%", key,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Delete", "count failed: "+key, err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return coreerr.E("sqlite.Delete", "directory not empty: "+key, os.ErrExist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := m.db.Exec(`DELETE FROM `+m.table+` WHERE path = ?`, key)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Delete", "delete failed: "+key, err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return coreerr.E("sqlite.Delete", "path not found: "+key, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAll removes a file or directory and all its contents recursively.
|
||||||
|
func (m *Medium) DeleteAll(p string) error {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return coreerr.E("sqlite.DeleteAll", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := key + "/"
|
||||||
|
|
||||||
|
// Delete the exact path and all children
|
||||||
|
res, err := m.db.Exec(
|
||||||
|
`DELETE FROM `+m.table+` WHERE path = ? OR path LIKE ?`,
|
||||||
|
key, prefix+"%",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.DeleteAll", "delete failed: "+key, err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return coreerr.E("sqlite.DeleteAll", "path not found: "+key, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename moves a file or directory from oldPath to newPath.
|
||||||
|
func (m *Medium) Rename(oldPath, newPath string) error {
|
||||||
|
oldKey := cleanPath(oldPath)
|
||||||
|
newKey := cleanPath(newPath)
|
||||||
|
if oldKey == "" || newKey == "" {
|
||||||
|
return coreerr.E("sqlite.Rename", "both old and new paths are required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := m.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Rename", "begin tx failed", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Check if source exists
|
||||||
|
var content []byte
|
||||||
|
var mode int
|
||||||
|
var isDir bool
|
||||||
|
var mtime time.Time
|
||||||
|
err = tx.QueryRow(
|
||||||
|
`SELECT content, mode, is_dir, mtime FROM `+m.table+` WHERE path = ?`, oldKey,
|
||||||
|
).Scan(&content, &mode, &isDir, &mtime)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return coreerr.E("sqlite.Rename", "source not found: "+oldKey, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Rename", "query failed: "+oldKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert or replace at new path
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`INSERT INTO `+m.table+` (path, content, mode, is_dir, mtime) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET content = excluded.content, mode = excluded.mode, is_dir = excluded.is_dir, mtime = excluded.mtime`,
|
||||||
|
newKey, content, mode, isDir, mtime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Rename", "insert at new path failed: "+newKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old path
|
||||||
|
_, err = tx.Exec(`DELETE FROM `+m.table+` WHERE path = ?`, oldKey)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Rename", "delete old path failed: "+oldKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a directory, move all children
|
||||||
|
if isDir {
|
||||||
|
oldPrefix := oldKey + "/"
|
||||||
|
newPrefix := newKey + "/"
|
||||||
|
|
||||||
|
rows, err := tx.Query(
|
||||||
|
`SELECT path, content, mode, is_dir, mtime FROM `+m.table+` WHERE path LIKE ?`,
|
||||||
|
oldPrefix+"%",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Rename", "query children failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type child struct {
|
||||||
|
path string
|
||||||
|
content []byte
|
||||||
|
mode int
|
||||||
|
isDir bool
|
||||||
|
mtime time.Time
|
||||||
|
}
|
||||||
|
var children []child
|
||||||
|
for rows.Next() {
|
||||||
|
var c child
|
||||||
|
if err := rows.Scan(&c.path, &c.content, &c.mode, &c.isDir, &c.mtime); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return coreerr.E("sqlite.Rename", "scan child failed", err)
|
||||||
|
}
|
||||||
|
children = append(children, c)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, c := range children {
|
||||||
|
newChildPath := newPrefix + strings.TrimPrefix(c.path, oldPrefix)
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`INSERT INTO `+m.table+` (path, content, mode, is_dir, mtime) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET content = excluded.content, mode = excluded.mode, is_dir = excluded.is_dir, mtime = excluded.mtime`,
|
||||||
|
newChildPath, c.content, c.mode, c.isDir, c.mtime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Rename", "insert child failed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old children
|
||||||
|
_, err = tx.Exec(`DELETE FROM `+m.table+` WHERE path LIKE ?`, oldPrefix+"%")
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.Rename", "delete old children failed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the directory entries for the given path.
|
||||||
|
func (m *Medium) List(p string) ([]fs.DirEntry, error) {
|
||||||
|
prefix := cleanPath(p)
|
||||||
|
if prefix != "" {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query all paths under the prefix
|
||||||
|
rows, err := m.db.Query(
|
||||||
|
`SELECT path, content, mode, is_dir, mtime FROM `+m.table+` WHERE path LIKE ? OR path LIKE ?`,
|
||||||
|
prefix+"%", prefix+"%",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("sqlite.List", "query failed", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var entries []fs.DirEntry
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var rowPath string
|
||||||
|
var content []byte
|
||||||
|
var mode int
|
||||||
|
var isDir bool
|
||||||
|
var mtime time.Time
|
||||||
|
if err := rows.Scan(&rowPath, &content, &mode, &isDir, &mtime); err != nil {
|
||||||
|
return nil, coreerr.E("sqlite.List", "scan failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := strings.TrimPrefix(rowPath, prefix)
|
||||||
|
if rest == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a direct child or nested
|
||||||
|
if idx := strings.Index(rest, "/"); idx >= 0 {
|
||||||
|
// Nested - register as a directory
|
||||||
|
dirName := rest[:idx]
|
||||||
|
if !seen[dirName] {
|
||||||
|
seen[dirName] = true
|
||||||
|
entries = append(entries, &dirEntry{
|
||||||
|
name: dirName,
|
||||||
|
isDir: true,
|
||||||
|
mode: fs.ModeDir | 0755,
|
||||||
|
info: &fileInfo{
|
||||||
|
name: dirName,
|
||||||
|
isDir: true,
|
||||||
|
mode: fs.ModeDir | 0755,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct child
|
||||||
|
if !seen[rest] {
|
||||||
|
seen[rest] = true
|
||||||
|
entries = append(entries, &dirEntry{
|
||||||
|
name: rest,
|
||||||
|
isDir: isDir,
|
||||||
|
mode: fs.FileMode(mode),
|
||||||
|
info: &fileInfo{
|
||||||
|
name: rest,
|
||||||
|
size: int64(len(content)),
|
||||||
|
mode: fs.FileMode(mode),
|
||||||
|
modTime: mtime,
|
||||||
|
isDir: isDir,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file information for the given path.
|
||||||
|
func (m *Medium) Stat(p string) (fs.FileInfo, error) {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("sqlite.Stat", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []byte
|
||||||
|
var mode int
|
||||||
|
var isDir bool
|
||||||
|
var mtime time.Time
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT content, mode, is_dir, mtime FROM `+m.table+` WHERE path = ?`, key,
|
||||||
|
).Scan(&content, &mode, &isDir, &mtime)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, coreerr.E("sqlite.Stat", "path not found: "+key, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("sqlite.Stat", "query failed: "+key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := path.Base(key)
|
||||||
|
return &fileInfo{
|
||||||
|
name: name,
|
||||||
|
size: int64(len(content)),
|
||||||
|
mode: fs.FileMode(mode),
|
||||||
|
modTime: mtime,
|
||||||
|
isDir: isDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens the named file for reading.
|
||||||
|
func (m *Medium) Open(p string) (fs.File, error) {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("sqlite.Open", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []byte
|
||||||
|
var mode int
|
||||||
|
var isDir bool
|
||||||
|
var mtime time.Time
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT content, mode, is_dir, mtime FROM `+m.table+` WHERE path = ?`, key,
|
||||||
|
).Scan(&content, &mode, &isDir, &mtime)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, coreerr.E("sqlite.Open", "file not found: "+key, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("sqlite.Open", "query failed: "+key, err)
|
||||||
|
}
|
||||||
|
if isDir {
|
||||||
|
return nil, coreerr.E("sqlite.Open", "path is a directory: "+key, os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sqliteFile{
|
||||||
|
name: path.Base(key),
|
||||||
|
content: content,
|
||||||
|
mode: fs.FileMode(mode),
|
||||||
|
modTime: mtime,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates or truncates the named file.
|
||||||
|
func (m *Medium) Create(p string) (goio.WriteCloser, error) {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("sqlite.Create", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
return &sqliteWriteCloser{
|
||||||
|
medium: m,
|
||||||
|
path: key,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append opens the named file for appending, creating it if it doesn't exist.
|
||||||
|
func (m *Medium) Append(p string) (goio.WriteCloser, error) {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("sqlite.Append", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing []byte
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT content FROM `+m.table+` WHERE path = ? AND is_dir = FALSE`, key,
|
||||||
|
).Scan(&existing)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, coreerr.E("sqlite.Append", "query failed: "+key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sqliteWriteCloser{
|
||||||
|
medium: m,
|
||||||
|
path: key,
|
||||||
|
data: existing,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadStream returns a reader for the file content.
|
||||||
|
func (m *Medium) ReadStream(p string) (goio.ReadCloser, error) {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return nil, coreerr.E("sqlite.ReadStream", "path is required", os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []byte
|
||||||
|
var isDir bool
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT content, is_dir FROM `+m.table+` WHERE path = ?`, key,
|
||||||
|
).Scan(&content, &isDir)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, coreerr.E("sqlite.ReadStream", "file not found: "+key, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("sqlite.ReadStream", "query failed: "+key, err)
|
||||||
|
}
|
||||||
|
if isDir {
|
||||||
|
return nil, coreerr.E("sqlite.ReadStream", "path is a directory: "+key, os.ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return goio.NopCloser(bytes.NewReader(content)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteStream returns a writer for the file content. Content is stored on Close.
|
||||||
|
func (m *Medium) WriteStream(p string) (goio.WriteCloser, error) {
|
||||||
|
return m.Create(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a path exists (file or directory).
|
||||||
|
func (m *Medium) Exists(p string) bool {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
// Root always exists
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM `+m.table+` WHERE path = ?`, key,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDir checks if a path exists and is a directory.
|
||||||
|
func (m *Medium) IsDir(p string) bool {
|
||||||
|
key := cleanPath(p)
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDir bool
|
||||||
|
err := m.db.QueryRow(
|
||||||
|
`SELECT is_dir FROM `+m.table+` WHERE path = ?`, key,
|
||||||
|
).Scan(&isDir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal types ---
|
||||||
|
|
||||||
|
// fileInfo implements fs.FileInfo for SQLite entries.
|
||||||
|
type fileInfo struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
mode fs.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
isDir bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi *fileInfo) Name() string { return fi.name }
|
||||||
|
func (fi *fileInfo) Size() int64 { return fi.size }
|
||||||
|
func (fi *fileInfo) Mode() fs.FileMode { return fi.mode }
|
||||||
|
func (fi *fileInfo) ModTime() time.Time { return fi.modTime }
|
||||||
|
func (fi *fileInfo) IsDir() bool { return fi.isDir }
|
||||||
|
func (fi *fileInfo) Sys() any { return nil }
|
||||||
|
|
||||||
|
// dirEntry implements fs.DirEntry for SQLite listings.
|
||||||
|
type dirEntry struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
mode fs.FileMode
|
||||||
|
info fs.FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (de *dirEntry) Name() string { return de.name }
|
||||||
|
func (de *dirEntry) IsDir() bool { return de.isDir }
|
||||||
|
func (de *dirEntry) Type() fs.FileMode { return de.mode.Type() }
|
||||||
|
func (de *dirEntry) Info() (fs.FileInfo, error) { return de.info, nil }
|
||||||
|
|
||||||
|
// sqliteFile implements fs.File for SQLite entries.
|
||||||
|
type sqliteFile struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
offset int64
|
||||||
|
mode fs.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *sqliteFile) Stat() (fs.FileInfo, error) {
|
||||||
|
return &fileInfo{
|
||||||
|
name: f.name,
|
||||||
|
size: int64(len(f.content)),
|
||||||
|
mode: f.mode,
|
||||||
|
modTime: f.modTime,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *sqliteFile) Read(b []byte) (int, error) {
|
||||||
|
if f.offset >= int64(len(f.content)) {
|
||||||
|
return 0, goio.EOF
|
||||||
|
}
|
||||||
|
n := copy(b, f.content[f.offset:])
|
||||||
|
f.offset += int64(n)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *sqliteFile) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqliteWriteCloser buffers writes and stores to SQLite on Close.
|
||||||
|
type sqliteWriteCloser struct {
|
||||||
|
medium *Medium
|
||||||
|
path string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *sqliteWriteCloser) Write(p []byte) (int, error) {
|
||||||
|
w.data = append(w.data, p...)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *sqliteWriteCloser) Close() error {
|
||||||
|
_, err := w.medium.db.Exec(
|
||||||
|
`INSERT INTO `+w.medium.table+` (path, content, mode, is_dir, mtime) VALUES (?, ?, 420, FALSE, ?)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET content = excluded.content, is_dir = FALSE, mtime = excluded.mtime`,
|
||||||
|
w.path, w.data, time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("sqlite.WriteCloser.Close", "store failed: "+w.path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
653
pkg/io/sqlite/sqlite_test.go
Normal file
|
|
@ -0,0 +1,653 @@
|
||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
goio "io"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestMedium(t *testing.T) *Medium {
|
||||||
|
t.Helper()
|
||||||
|
m, err := New(":memory:")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { m.Close() })
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Constructor Tests ---
|
||||||
|
|
||||||
|
func TestNew_Good(t *testing.T) {
|
||||||
|
m, err := New(":memory:")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer m.Close()
|
||||||
|
assert.Equal(t, "files", m.table)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_Good_WithTable(t *testing.T) {
|
||||||
|
m, err := New(":memory:", WithTable("custom"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer m.Close()
|
||||||
|
assert.Equal(t, "custom", m.table)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_Bad_EmptyPath(t *testing.T) {
|
||||||
|
_, err := New("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "database path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Read/Write Tests ---
|
||||||
|
|
||||||
|
func TestReadWrite_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Write("hello.txt", "world")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := m.Read("hello.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "world", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadWrite_Good_Overwrite(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "first"))
|
||||||
|
require.NoError(t, m.Write("file.txt", "second"))
|
||||||
|
|
||||||
|
content, err := m.Read("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "second", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadWrite_Good_NestedPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Write("a/b/c.txt", "nested")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := m.Read("a/b/c.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "nested", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_Bad_NotFound(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Read("nonexistent.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Read("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrite_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Write("", "content")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_Bad_IsDirectory(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
_, err := m.Read("mydir")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EnsureDir Tests ---
|
||||||
|
|
||||||
|
func TestEnsureDir_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.EnsureDir("mydir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, m.IsDir("mydir"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureDir_Good_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
// Root always exists, no-op
|
||||||
|
err := m.EnsureDir("")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureDir_Good_Idempotent(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
assert.True(t, m.IsDir("mydir"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IsFile Tests ---
|
||||||
|
|
||||||
|
func TestIsFile_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "content"))
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
|
||||||
|
assert.True(t, m.IsFile("file.txt"))
|
||||||
|
assert.False(t, m.IsFile("mydir"))
|
||||||
|
assert.False(t, m.IsFile("nonexistent"))
|
||||||
|
assert.False(t, m.IsFile(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FileGet/FileSet Tests ---
|
||||||
|
|
||||||
|
func TestFileGetFileSet_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.FileSet("key.txt", "value")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
val, err := m.FileGet("key.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "value", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete Tests ---
|
||||||
|
|
||||||
|
func TestDelete_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("to-delete.txt", "content"))
|
||||||
|
assert.True(t, m.Exists("to-delete.txt"))
|
||||||
|
|
||||||
|
err := m.Delete("to-delete.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, m.Exists("to-delete.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_Good_EmptyDir(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.EnsureDir("emptydir"))
|
||||||
|
assert.True(t, m.IsDir("emptydir"))
|
||||||
|
|
||||||
|
err := m.Delete("emptydir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, m.IsDir("emptydir"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_Bad_NotFound(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Delete("nonexistent")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Delete("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_Bad_NotEmpty(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
require.NoError(t, m.Write("mydir/file.txt", "content"))
|
||||||
|
|
||||||
|
err := m.Delete("mydir")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DeleteAll Tests ---
|
||||||
|
|
||||||
|
func TestDeleteAll_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("dir/file1.txt", "a"))
|
||||||
|
require.NoError(t, m.Write("dir/sub/file2.txt", "b"))
|
||||||
|
require.NoError(t, m.Write("other.txt", "c"))
|
||||||
|
|
||||||
|
err := m.DeleteAll("dir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, m.Exists("dir/file1.txt"))
|
||||||
|
assert.False(t, m.Exists("dir/sub/file2.txt"))
|
||||||
|
assert.True(t, m.Exists("other.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAll_Good_SingleFile(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "content"))
|
||||||
|
|
||||||
|
err := m.DeleteAll("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, m.Exists("file.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAll_Bad_NotFound(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.DeleteAll("nonexistent")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAll_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.DeleteAll("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rename Tests ---
|
||||||
|
|
||||||
|
func TestRename_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("old.txt", "content"))
|
||||||
|
|
||||||
|
err := m.Rename("old.txt", "new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, m.Exists("old.txt"))
|
||||||
|
assert.True(t, m.IsFile("new.txt"))
|
||||||
|
|
||||||
|
content, err := m.Read("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "content", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRename_Good_Directory(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.EnsureDir("olddir"))
|
||||||
|
require.NoError(t, m.Write("olddir/file.txt", "content"))
|
||||||
|
|
||||||
|
err := m.Rename("olddir", "newdir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, m.Exists("olddir"))
|
||||||
|
assert.False(t, m.Exists("olddir/file.txt"))
|
||||||
|
assert.True(t, m.IsDir("newdir"))
|
||||||
|
assert.True(t, m.IsFile("newdir/file.txt"))
|
||||||
|
|
||||||
|
content, err := m.Read("newdir/file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "content", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRename_Bad_SourceNotFound(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Rename("nonexistent", "new")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRename_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
err := m.Rename("", "new")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
err = m.Rename("old", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- List Tests ---
|
||||||
|
|
||||||
|
func TestList_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("dir/file1.txt", "a"))
|
||||||
|
require.NoError(t, m.Write("dir/file2.txt", "b"))
|
||||||
|
require.NoError(t, m.Write("dir/sub/file3.txt", "c"))
|
||||||
|
|
||||||
|
entries, err := m.List("dir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
names := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
names[e.Name()] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, names["file1.txt"])
|
||||||
|
assert.True(t, names["file2.txt"])
|
||||||
|
assert.True(t, names["sub"])
|
||||||
|
assert.Len(t, entries, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList_Good_Root(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("root.txt", "content"))
|
||||||
|
require.NoError(t, m.Write("dir/nested.txt", "nested"))
|
||||||
|
|
||||||
|
entries, err := m.List("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
names := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
names[e.Name()] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, names["root.txt"])
|
||||||
|
assert.True(t, names["dir"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList_Good_DirectoryEntry(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("dir/sub/file.txt", "content"))
|
||||||
|
|
||||||
|
entries, err := m.List("dir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, entries, 1)
|
||||||
|
assert.Equal(t, "sub", entries[0].Name())
|
||||||
|
assert.True(t, entries[0].IsDir())
|
||||||
|
|
||||||
|
info, err := entries[0].Info()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, info.IsDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stat Tests ---
|
||||||
|
|
||||||
|
func TestStat_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "hello world"))
|
||||||
|
|
||||||
|
info, err := m.Stat("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "file.txt", info.Name())
|
||||||
|
assert.Equal(t, int64(11), info.Size())
|
||||||
|
assert.False(t, info.IsDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStat_Good_Directory(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
|
||||||
|
info, err := m.Stat("mydir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "mydir", info.Name())
|
||||||
|
assert.True(t, info.IsDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStat_Bad_NotFound(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Stat("nonexistent")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStat_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Stat("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Open Tests ---
|
||||||
|
|
||||||
|
func TestOpen_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "open me"))
|
||||||
|
|
||||||
|
f, err := m.Open("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
data, err := goio.ReadAll(f.(goio.Reader))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "open me", string(data))
|
||||||
|
|
||||||
|
stat, err := f.Stat()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "file.txt", stat.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_Bad_NotFound(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Open("nonexistent.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_Bad_IsDirectory(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
_, err := m.Open("mydir")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create Tests ---
|
||||||
|
|
||||||
|
func TestCreate_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
w, err := m.Create("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
n, err := w.Write([]byte("created"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 7, n)
|
||||||
|
|
||||||
|
err = w.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := m.Read("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "created", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_Good_Overwrite(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "old content"))
|
||||||
|
|
||||||
|
w, err := m.Create("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("new"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
content, err := m.Read("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "new", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Create("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Append Tests ---
|
||||||
|
|
||||||
|
func TestAppend_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("append.txt", "hello"))
|
||||||
|
|
||||||
|
w, err := m.Append("append.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = w.Write([]byte(" world"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
content, err := m.Read("append.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello world", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppend_Good_NewFile(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
w, err := m.Append("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = w.Write([]byte("fresh"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
content, err := m.Read("new.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "fresh", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppend_Bad_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.Append("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ReadStream Tests ---
|
||||||
|
|
||||||
|
func TestReadStream_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("stream.txt", "streaming content"))
|
||||||
|
|
||||||
|
reader, err := m.ReadStream("stream.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
data, err := goio.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "streaming content", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadStream_Bad_NotFound(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
_, err := m.ReadStream("nonexistent.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadStream_Bad_IsDirectory(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
_, err := m.ReadStream("mydir")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WriteStream Tests ---
|
||||||
|
|
||||||
|
func TestWriteStream_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
writer, err := m.WriteStream("output.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = goio.Copy(writer, strings.NewReader("piped data"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
content, err := m.Read("output.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "piped data", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Exists Tests ---
|
||||||
|
|
||||||
|
func TestExists_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
assert.False(t, m.Exists("nonexistent"))
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "content"))
|
||||||
|
assert.True(t, m.Exists("file.txt"))
|
||||||
|
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
assert.True(t, m.Exists("mydir"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExists_Good_EmptyPath(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
// Root always exists
|
||||||
|
assert.True(t, m.Exists(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IsDir Tests ---
|
||||||
|
|
||||||
|
func TestIsDir_Good(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "content"))
|
||||||
|
require.NoError(t, m.EnsureDir("mydir"))
|
||||||
|
|
||||||
|
assert.True(t, m.IsDir("mydir"))
|
||||||
|
assert.False(t, m.IsDir("file.txt"))
|
||||||
|
assert.False(t, m.IsDir("nonexistent"))
|
||||||
|
assert.False(t, m.IsDir(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cleanPath Tests ---
|
||||||
|
|
||||||
|
func TestCleanPath_Good(t *testing.T) {
|
||||||
|
assert.Equal(t, "file.txt", cleanPath("file.txt"))
|
||||||
|
assert.Equal(t, "dir/file.txt", cleanPath("dir/file.txt"))
|
||||||
|
assert.Equal(t, "file.txt", cleanPath("/file.txt"))
|
||||||
|
assert.Equal(t, "file.txt", cleanPath("../file.txt"))
|
||||||
|
assert.Equal(t, "file.txt", cleanPath("dir/../file.txt"))
|
||||||
|
assert.Equal(t, "", cleanPath(""))
|
||||||
|
assert.Equal(t, "", cleanPath("."))
|
||||||
|
assert.Equal(t, "", cleanPath("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interface Compliance ---
|
||||||
|
|
||||||
|
func TestInterfaceCompliance_Ugly(t *testing.T) {
|
||||||
|
m := newTestMedium(t)
|
||||||
|
|
||||||
|
// Verify all methods exist by asserting the interface shape.
|
||||||
|
var _ interface {
|
||||||
|
Read(string) (string, error)
|
||||||
|
Write(string, string) error
|
||||||
|
EnsureDir(string) error
|
||||||
|
IsFile(string) bool
|
||||||
|
FileGet(string) (string, error)
|
||||||
|
FileSet(string, string) error
|
||||||
|
Delete(string) error
|
||||||
|
DeleteAll(string) error
|
||||||
|
Rename(string, string) error
|
||||||
|
List(string) ([]fs.DirEntry, error)
|
||||||
|
Stat(string) (fs.FileInfo, error)
|
||||||
|
Open(string) (fs.File, error)
|
||||||
|
Create(string) (goio.WriteCloser, error)
|
||||||
|
Append(string) (goio.WriteCloser, error)
|
||||||
|
ReadStream(string) (goio.ReadCloser, error)
|
||||||
|
WriteStream(string) (goio.WriteCloser, error)
|
||||||
|
Exists(string) bool
|
||||||
|
IsDir(string) bool
|
||||||
|
} = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Custom Table ---
|
||||||
|
|
||||||
|
func TestCustomTable_Good(t *testing.T) {
|
||||||
|
m, err := New(":memory:", WithTable("my_files"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
require.NoError(t, m.Write("file.txt", "content"))
|
||||||
|
|
||||||
|
content, err := m.Read("file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "content", content)
|
||||||
|
}
|
||||||
182
pkg/mcp/ide/bridge.go
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
package ide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/host-uk/core/pkg/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BridgeMessage is the wire format between the IDE and Laravel.
|
||||||
|
type BridgeMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
SessionID string `json:"sessionId,omitempty"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge maintains a WebSocket connection to the Laravel core-agentic
|
||||||
|
// backend and forwards responses to a local ws.Hub.
|
||||||
|
type Bridge struct {
|
||||||
|
cfg Config
|
||||||
|
hub *ws.Hub
|
||||||
|
conn *websocket.Conn
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
connected bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBridge creates a bridge that will connect to the Laravel backend and
|
||||||
|
// forward incoming messages to the provided ws.Hub channels.
|
||||||
|
func NewBridge(hub *ws.Hub, cfg Config) *Bridge {
|
||||||
|
return &Bridge{cfg: cfg, hub: hub}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the connection loop in a background goroutine.
|
||||||
|
// Call Shutdown to stop it.
|
||||||
|
func (b *Bridge) Start(ctx context.Context) {
|
||||||
|
ctx, b.cancel = context.WithCancel(ctx)
|
||||||
|
go b.connectLoop(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown cleanly closes the bridge.
|
||||||
|
func (b *Bridge) Shutdown() {
|
||||||
|
if b.cancel != nil {
|
||||||
|
b.cancel()
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.conn != nil {
|
||||||
|
b.conn.Close()
|
||||||
|
b.conn = nil
|
||||||
|
}
|
||||||
|
b.connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected reports whether the bridge has an active connection.
|
||||||
|
func (b *Bridge) Connected() bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to the Laravel backend.
|
||||||
|
func (b *Bridge) Send(msg BridgeMessage) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.conn == nil {
|
||||||
|
return fmt.Errorf("bridge: not connected")
|
||||||
|
}
|
||||||
|
msg.Timestamp = time.Now()
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bridge: marshal failed: %w", err)
|
||||||
|
}
|
||||||
|
return b.conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectLoop reconnects to Laravel with exponential backoff.
|
||||||
|
func (b *Bridge) connectLoop(ctx context.Context) {
|
||||||
|
delay := b.cfg.ReconnectInterval
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.dial(ctx); err != nil {
|
||||||
|
log.Printf("ide bridge: connect failed: %v", err)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
delay = min(delay*2, b.cfg.MaxReconnectInterval)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset backoff on successful connection
|
||||||
|
delay = b.cfg.ReconnectInterval
|
||||||
|
b.readLoop(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) dial(ctx context.Context) error {
|
||||||
|
dialer := websocket.Dialer{
|
||||||
|
HandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
conn, _, err := dialer.DialContext(ctx, b.cfg.LaravelWSURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.conn = conn
|
||||||
|
b.connected = true
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("ide bridge: connected to %s", b.cfg.LaravelWSURL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) readLoop(ctx context.Context) {
|
||||||
|
defer func() {
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.conn != nil {
|
||||||
|
b.conn.Close()
|
||||||
|
}
|
||||||
|
b.connected = false
|
||||||
|
b.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_, data, err := b.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ide bridge: read error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg BridgeMessage
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
log.Printf("ide bridge: unmarshal error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.dispatch(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch routes an incoming message to the appropriate ws.Hub channel.
|
||||||
|
func (b *Bridge) dispatch(msg BridgeMessage) {
|
||||||
|
if b.hub == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wsMsg := ws.Message{
|
||||||
|
Type: ws.TypeEvent,
|
||||||
|
Data: msg.Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := msg.Channel
|
||||||
|
if channel == "" {
|
||||||
|
channel = "ide:" + msg.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.hub.SendToChannel(channel, wsMsg); err != nil {
|
||||||
|
log.Printf("ide bridge: dispatch to %s failed: %v", channel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||