Add GitHub plugin release imports and update discovery

This commit is contained in:
2026-09-18 01:29:38 -04:00
parent a718efc9e7
commit 3c49437bf9
16 changed files with 847 additions and 2 deletions
+38
View File
@@ -0,0 +1,38 @@
export interface GithubPluginAsset {
id: number;
name: string;
size: number;
digest: string | null;
}
export interface GithubPluginRelease {
repository: string;
id: number;
tag: string;
url: string;
name: string;
notes: string;
publishedAt: string;
prerelease: boolean;
assets: GithubPluginAsset[];
}
export interface PluginSource {
version_id: number;
repository: string;
release_id: number;
release_url: string;
asset_id: number;
asset_name: string;
published_at: string;
}
export interface PluginUpdate {
pluginId: number;
name: string;
source: PluginSource | null;
release: GithubPluginRelease | null;
status: 'untracked' | 'available' | 'imported' | 'current' | 'ahead' | 'error';
versionId?: number;
error?: string;
}
+11
View File
@@ -84,6 +84,17 @@ CREATE TABLE IF NOT EXISTS script_versions (
);
CREATE INDEX IF NOT EXISTS idx_script_versions_script ON script_versions(script_id);
-- Provenance belongs to the exact binary version, including when it is rolled back.
CREATE TABLE IF NOT EXISTS plugin_imports (
version_id INTEGER PRIMARY KEY REFERENCES script_versions(id) ON DELETE CASCADE,
repository TEXT NOT NULL,
release_id INTEGER NOT NULL,
release_url TEXT NOT NULL,
asset_id INTEGER NOT NULL,
asset_name TEXT NOT NULL,
published_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_scripts (
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
rel_path TEXT NOT NULL,
+85
View File
@@ -0,0 +1,85 @@
import { createHash } from 'node:crypto';
import { db } from '../db/index.js';
import { createScript, getScript, getVersion, uploadNewVersion, type ScopeType } from './scripts.js';
import { findRunningBetaForScript } from './beta.js';
import { downloadGithubAsset, getGithubRelease, GithubReleaseError } from '../integrations/github-releases.js';
import type { PluginSource, PluginUpdate } from '$lib/plugin-github.js';
export function pluginSource(pluginId: number): PluginSource | null {
return db().prepare(`SELECT p.* FROM plugin_imports p JOIN script_versions v ON v.id = p.version_id
WHERE v.script_id = ? ORDER BY v.version DESC LIMIT 1`).get(pluginId) as PluginSource | undefined ?? null;
}
export async function checkPluginUpdate(pluginId: number): Promise<PluginUpdate> {
const plugin = getScript(pluginId);
if (!plugin || plugin.kind !== 'plugin') throw new GithubReleaseError('Plugin not found.', 404);
const source = pluginSource(pluginId);
const base = { pluginId, name: plugin.name, source };
if (!source) return { ...base, release: null, status: 'untracked' };
try {
const release = await getGithubRelease(`https://github.com/${source.repository}`);
// A latest-stable release may already be in history when a newer prerelease is imported.
if (release.id !== source.release_id && Date.parse(release.publishedAt) <= Date.parse(source.published_at)) {
return { ...base, release, status: 'ahead' };
}
const imported = db().prepare(`SELECT p.version_id FROM plugin_imports p JOIN script_versions v ON v.id = p.version_id
WHERE v.script_id = ? AND lower(p.repository) = lower(?) AND p.release_id = ? AND p.asset_name = ?
ORDER BY v.version DESC LIMIT 1`).get(pluginId, source.repository, release.id, source.asset_name) as { version_id: number } | undefined;
if (imported) return { ...base, release, status: imported.version_id === plugin.current_version_id ? 'current' : 'imported', versionId: imported.version_id };
return { ...base, release, status: 'available' };
} catch (error) {
return { ...base, release: null, status: 'error', error: (error as Error).message };
}
}
export interface ImportPluginReleaseInput {
url: string;
assetId: number;
pluginId?: number;
name?: string;
scopeType?: ScopeType | null;
scopeValue?: string | null;
}
export async function importPluginRelease(input: ImportPluginReleaseInput) {
if (typeof input.url !== 'string' || !Number.isSafeInteger(input.assetId) || input.assetId < 1) {
throw new GithubReleaseError('A release URL and DLL asset selection are required.');
}
if (input.pluginId !== undefined && (!Number.isSafeInteger(input.pluginId) || input.pluginId < 1)) {
throw new GithubReleaseError('Invalid plugin id.');
}
const requirePlugin = () => {
const plugin = input.pluginId === undefined ? undefined : getScript(input.pluginId);
if (input.pluginId !== undefined && (!plugin || plugin.kind !== 'plugin')) throw new GithubReleaseError('Plugin not found.', 404);
if (plugin && findRunningBetaForScript(plugin.id)) throw new GithubReleaseError('Finish the running beta before importing another release.', 409);
return plugin;
};
requirePlugin();
if (input.pluginId === undefined && (typeof input.name !== 'string' || !input.name.trim())) throw new GithubReleaseError('A plugin name is required.');
// Refetch the exact reviewed release: asset names, notes, tags and digests are never trusted from the client.
const release = await getGithubRelease(input.url, true);
const asset = release.assets.find(a => a.id === input.assetId);
if (!asset) throw new GithubReleaseError('That DLL is no longer available in this release. Preview it again.');
const content = await downloadGithubAsset(release, asset);
const sha = createHash('sha256').update(content).digest('hex');
return db().transaction(() => {
const plugin = requirePlugin();
if (plugin) {
const prior = db().prepare(`SELECT v.id FROM script_versions v JOIN plugin_imports p ON p.version_id = v.id
WHERE v.script_id = ? AND lower(p.repository) = lower(?) AND p.release_id = ? AND p.asset_id = ? AND v.sha256 = ?
ORDER BY v.version DESC LIMIT 1`).get(plugin.id, release.repository, release.id, asset.id, sha) as { id: number } | undefined;
if (prior) return result(plugin.id, getVersion(prior.id)!, true);
}
const created = plugin ? { script: plugin, version: uploadNewVersion(plugin.id, content, release.notes, release.tag) }
: createScript({ name: input.name!.trim(), kind: 'plugin', targetRelPath: asset.name, content,
releaseVersion: release.tag, notes: release.notes, scopeType: input.scopeType, scopeValue: input.scopeValue });
db().prepare(`INSERT INTO plugin_imports (version_id, repository, release_id, release_url, asset_id, asset_name, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(created.version.id, release.repository, release.id, release.url, asset.id, asset.name, release.publishedAt);
return result(created.script.id, created.version, false);
})();
}
function result(pluginId: number, version: NonNullable<ReturnType<typeof getVersion>>, alreadyImported: boolean) {
return { pluginId, versionId: version.id, releaseVersion: version.release_version, sha256: version.sha256,
alreadyImported, betaUrl: `/betas?pluginId=${pluginId}&versionId=${version.id}` };
}
@@ -0,0 +1,140 @@
import { createHash } from 'node:crypto';
import type { GithubPluginAsset, GithubPluginRelease } from '$lib/plugin-github.js';
const MAX_DLL_BYTES = 64 * 1024 * 1024;
const CACHE_MS = 5 * 60 * 1000;
const cache = new Map<string, { expires: number; release: Promise<GithubPluginRelease> }>();
export class GithubReleaseError extends Error {
constructor(message: string, public readonly status = 400) { super(message); }
}
export function parseGithubReleaseUrl(input: string): { repository: string; tag: string | null } {
let url: URL;
try { url = new URL(input); } catch { throw new GithubReleaseError('Enter a GitHub repository or release URL.'); }
if (url.protocol !== 'https:' || url.hostname !== 'github.com' || url.port || url.username || url.password || url.search) {
throw new GithubReleaseError('Use an https://github.com repository or release URL.');
}
const match = /^\/([\w-]+)\/([\w.-]+)(?:\/(.*?))?\/?$/.exec(url.pathname);
if (!match || match[2] === '.' || match[2] === '..') throw new GithubReleaseError('Invalid GitHub repository URL.');
const repository = `${match[1]}/${match[2]}`;
const path = (match[3] ?? '').replace(/\/$/, '');
let tag: string | null = null;
try {
if (path.startsWith('releases/tag/')) tag = decodeURIComponent(path.slice(13));
else if (path === 'releases' && url.hash.startsWith('#release-')) tag = decodeURIComponent(url.hash.slice(9));
else if (!['', 'releases', 'releases/latest'].includes(path) || url.hash) throw new Error();
} catch { throw new GithubReleaseError('Use a repository URL or a link to a specific GitHub release.'); }
if (tag !== null && (!tag || tag.length > 80 || /[\u0000-\u001f\u007f]/.test(tag))) {
throw new GithubReleaseError('Release tags must contain 1–80 characters on one line.');
}
return { repository, tag };
}
function safeAssetName(name: unknown): name is string {
return typeof name === 'string' && /^[^<>:"/\\|?*\u0000-\u001f]+\.dll$/i.test(name) && name.length <= 200;
}
async function limitedBody(response: Response, limit: number): Promise<Buffer> {
if (Number(response.headers.get('content-length')) > limit) {
await response.body?.cancel();
throw new GithubReleaseError('GitHub response exceeds the download size limit.', 502);
}
if (!response.body) throw new GithubReleaseError('GitHub returned an empty response.', 502);
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
size += next.value.byteLength;
if (size > limit) throw new GithubReleaseError('GitHub response exceeds the download size limit.', 502);
chunks.push(next.value);
}
} finally { await reader.cancel(); }
return Buffer.concat(chunks);
}
async function fetchRelease(repository: string, tag: string | null): Promise<GithubPluginRelease> {
const suffix = tag === null ? 'latest' : `tags/${encodeURIComponent(tag)}`;
try {
const response = await fetch(`https://api.github.com/repos/${repository}/releases/${suffix}`, {
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Electric-Cherry', 'X-GitHub-Api-Version': '2022-11-28' },
signal: AbortSignal.timeout(15_000), redirect: 'error'
});
if (!response.ok) {
await response.body?.cancel();
if (response.status === 404) throw new GithubReleaseError('No published release found. Check the repository and tag; only public repositories are supported.', 404);
if ([403, 429].includes(response.status)) throw new GithubReleaseError('GitHub is limiting requests. Try checking again later.', 429);
throw new GithubReleaseError(`GitHub could not load this release (HTTP ${response.status}).`, 502);
}
const data = JSON.parse((await limitedBody(response, 2 * 1024 * 1024)).toString('utf8'));
if (!Number.isSafeInteger(data.id) || data.id < 1 || data.draft || typeof data.tag_name !== 'string' ||
!data.tag_name.trim() || data.tag_name.length > 80 || /[\u0000-\u001f\u007f]/.test(data.tag_name) ||
!Number.isFinite(Date.parse(data.published_at)) || !Array.isArray(data.assets)) {
throw new GithubReleaseError('GitHub returned invalid release metadata.', 502);
}
return {
repository, id: data.id, tag: data.tag_name,
url: `https://github.com/${repository}/releases/tag/${encodeURIComponent(data.tag_name)}`,
name: typeof data.name === 'string' ? data.name : data.tag_name,
notes: typeof data.body === 'string' ? data.body : '', publishedAt: data.published_at,
prerelease: data.prerelease === true,
assets: data.assets.filter((a: any) => a && Number.isSafeInteger(a.id) && a.id > 0 && safeAssetName(a.name) &&
a.state === 'uploaded' && Number.isSafeInteger(a.size) && a.size > 0 && a.size <= MAX_DLL_BYTES)
.map((a: any) => ({ id: a.id, name: a.name, size: a.size, digest: typeof a.digest === 'string' ? a.digest : null }))
};
} catch (error) {
if (error instanceof GithubReleaseError) throw error;
throw new GithubReleaseError('Could not reach GitHub or read the release. Try again shortly.', 502);
}
}
export async function getGithubRelease(url: string, fresh = false): Promise<GithubPluginRelease> {
const { repository, tag } = parseGithubReleaseUrl(url);
const key = `${repository.toLowerCase()}:${tag ?? '<latest>'}`;
const hit = cache.get(key);
if (!fresh && hit && hit.expires > Date.now()) return hit.release;
if (cache.size >= 100) cache.delete(cache.keys().next().value!);
const release = fetchRelease(repository, tag);
cache.set(key, { expires: Date.now() + CACHE_MS, release });
try { return await release; } catch (error) { cache.delete(key); throw error; }
}
export async function downloadGithubAsset(release: GithubPluginRelease, asset: GithubPluginAsset): Promise<Buffer> {
// Construct the URL from validated metadata. Never fetch a client-supplied download URL.
let url = new URL(`https://github.com/${release.repository}/releases/download/${encodeURIComponent(release.tag)}/${encodeURIComponent(asset.name)}`);
const signal = AbortSignal.timeout(60_000);
try {
for (let i = 0; i < 5; i++) {
if (url.protocol !== 'https:' || url.port || url.username || url.password ||
!['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com'].includes(url.hostname)) {
throw new GithubReleaseError('GitHub redirected this download to an unsupported host.', 502);
}
const response = await fetch(url, { redirect: 'manual', signal, headers: { 'User-Agent': 'Electric-Cherry' } });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
const location = response.headers.get('location');
if (!location) break;
url = new URL(location, url);
continue;
}
if (!response.ok) {
await response.body?.cancel();
throw new GithubReleaseError(`GitHub asset download failed (HTTP ${response.status}).`, 502);
}
const content = await limitedBody(response, MAX_DLL_BYTES);
if (content.length !== asset.size) throw new GithubReleaseError('Downloaded size differs from the release asset. Preview the release again.', 502);
const sha = createHash('sha256').update(content).digest('hex');
if (asset.digest?.startsWith('sha256:') && asset.digest.toLowerCase() !== `sha256:${sha}`) {
throw new GithubReleaseError('Downloaded checksum does not match GitHub’s asset digest.', 502);
}
return content;
}
throw new GithubReleaseError('GitHub asset download redirected too many times.', 502);
} catch (error) {
if (error instanceof GithubReleaseError) throw error;
throw new GithubReleaseError('Could not download the DLL from GitHub. Try again shortly.', 502);
}
}
+12 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import PluginGithubImport from './PluginGithubImport.svelte';
import type { PluginSource } from '$lib/plugin-github.js';
import { goto } from '$app/navigation';
import Card from './primitives/Card.svelte';
import Badge from './primitives/Badge.svelte';
@@ -30,6 +32,7 @@
interface Version {
id: number; version: number; sha256: string; size: number;
release_version: string | null;
source: PluginSource | null;
notes: string | null; state: 'untested' | 'tested' | 'test-failed';
uploaded_at: string;
}
@@ -340,7 +343,9 @@
<span class="v-num">{artifactVersionLabel(v)}</span>
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
<span class="v-size">{v.size} B</span>
<span class="v-notes">{v.notes ?? ''}</span>
<span class="v-notes">{v.notes ?? ''}
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
</span>
<span class="v-when">{v.uploaded_at}</span>
{#if a.current_version_id === v.id}
<Badge variant="online" size="sm">Promoted</Badge>
@@ -353,6 +358,10 @@
{/if}
</Card>
{#if family === 'plugin'}
<div id="github-releases"><PluginGithubImport pluginId={id} targetPath={a.target_rel_path} disabled={runningBetaId !== null} onimport={() => load()} /></div>
{/if}
<Card padding="lg">
<div class="panel-head"><h2>Danger zone</h2></div>
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
@@ -500,6 +509,8 @@
.v-num { color: var(--text-primary); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
.v-sha, .v-size, .v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.v-notes { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-sm); }
.v-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow-y: auto; }
.v-notes a { display: block; color: var(--accent-primary-hover); margin-top: var(--space-2); }
.alert {
padding: var(--space-3) var(--space-4);
@@ -0,0 +1,175 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js';
import Card from './primitives/Card.svelte';
import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
let { pluginId, targetPath, disabled = false, onimport }: {
pluginId?: number; targetPath?: string; disabled?: boolean; onimport?: () => void | Promise<void>;
} = $props();
let url = $state('');
let name = $state('');
let game = $state('');
let release = $state<GithubPluginRelease | null>(null);
let assetId = $state('');
let update = $state<PluginUpdate | null>(null);
let busy = $state(false);
let checking = $state(false);
let error = $state('');
let success = $state<{ pluginId: number; betaUrl: string; sha256: string; alreadyImported: boolean } | null>(null);
function showRelease(value: GithubPluginRelease) {
release = value;
const preferred = update?.source?.asset_name ?? targetPath?.split('/').pop();
assetId = String(value.assets.find(a => a.name === preferred)?.id ?? (value.assets.length === 1 ? value.assets[0].id : ''));
if (!pluginId && !name) name = value.repository.split('/')[1];
url = value.url;
error = ''; success = null;
}
async function checkUpdates() {
if (!pluginId || checking) return;
checking = true; error = '';
try {
const response = await fetch(`/api/plugins/updates?pluginId=${pluginId}`);
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not check for updates.');
update = body.updates[0];
if (!url && update?.source) url = `https://github.com/${update.source.repository}`;
} catch (e) { error = (e as Error).message; }
finally { checking = false; }
}
onMount(() => { void checkUpdates(); });
async function preview(event: SubmitEvent) {
event.preventDefault();
if (busy || disabled) return;
busy = true; error = ''; release = null; success = null;
try {
const response = await fetch(`/api/plugins/github/release?url=${encodeURIComponent(url.trim())}`);
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load release.');
showRelease(body.release);
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
}
async function importRelease(startBeta: boolean) {
if (!release || !assetId || busy || disabled) return;
busy = true; error = ''; success = null;
try {
const response = await fetch('/api/plugins/github/import', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: release.url, assetId: Number(assetId), pluginId,
name: name.trim(), scopeType: game ? 'game' : null, scopeValue: game || null })
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Import failed.');
success = body;
await onimport?.();
if (startBeta) await goto(body.betaUrl);
else if (pluginId) await checkUpdates();
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
}
</script>
<Card padding="lg">
<section class="github" aria-label="GitHub plugin releases">
<div class="heading">
<div><h2>{pluginId ? 'GitHub releases' : 'Import from GitHub'}</h2><p>Choose a release and DLL, then test it with your fleet.</p></div>
{#if pluginId}<Button size="sm" onclick={checkUpdates} loading={checking} disabled={busy}>Check for updates</Button>{/if}
</div>
{#if checking}<p role="status">Checking GitHub…</p>{/if}
{#if update?.source}
<p>Following <a href={`https://github.com/${update.source.repository}`} target="_blank" rel="noreferrer">{update.source.repository}</a> · {update.source.asset_name}</p>
{/if}
{#if update?.status === 'available' && update.release}
<div class="update">
<Badge variant="info">New release: {update.release.tag}</Badge>
<Button size="sm" disabled={busy || disabled} onclick={() => showRelease(update!.release!)}>Review update</Button>
</div>
{:else if update?.status === 'imported' && update.release}
<div class="update"><span>{update.release.tag} is already imported and available for testing.</span>
<Button size="sm" href={`/betas?pluginId=${pluginId}&versionId=${update.versionId}`} disabled={disabled}>Start beta</Button></div>
{:else if update?.status === 'current'}
<p role="status">The latest stable release is promoted.</p>
{:else if update?.status === 'ahead'}
<p role="status">Your imported release is newer than GitHub’s latest stable release.</p>
{:else if update?.status === 'error'}
<p class="error" role="alert">{update.error}</p>
{:else if update?.status === 'untracked'}
<p>Import a release below to track updates for this plugin.</p>
{/if}
<form onsubmit={preview} class="lookup">
<label>Repository or release URL
<input type="url" bind:value={url} oninput={() => { release = null; success = null; }} required
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0" disabled={busy || disabled} />
</label>
<Button type="submit" loading={busy && !release} disabled={busy || disabled || !url.trim()}>Preview release</Button>
</form>
{#if disabled}<p>Finish the running beta before importing a release.</p>{/if}
{#if release}
<div class="preview">
<div class="heading"><h3>{release.name || release.tag}</h3><Badge variant={release.prerelease ? 'warning' : 'neutral'}>{release.prerelease ? 'Prerelease' : 'Release'} · {release.tag}</Badge></div>
<p><a href={release.url} target="_blank" rel="noreferrer">View on GitHub ↗</a> · Published {new Date(release.publishedAt).toLocaleDateString()}</p>
<details open><summary>Release notes</summary><pre>{release.notes || 'No release notes provided.'}</pre></details>
{#if release.assets.length === 0}
<p>No importable DLL assets found. Choose another release or upload a DLL manually. Direct DLL assets up to 64 MB are supported.</p>
{:else}
<div class="fields">
<label>DLL asset<select bind:value={assetId} disabled={busy || disabled}>
<option value="" disabled>Choose a DLL…</option>
{#each release.assets as asset}<option value={String(asset.id)}>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option>{/each}
</select></label>
{#if !pluginId}
<label>Plugin name<input bind:value={name} disabled={busy} placeholder="t6-gsc-utils" /></label>
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">T5 · Black Ops I</option><option value="t6">T6 · Black Ops II</option></select></label>
{/if}
</div>
{#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if}
<div class="actions">
<Button variant="primary" disabled={busy || disabled || !assetId || (!pluginId && !name.trim())} loading={busy} onclick={() => importRelease(true)}>Import and test</Button>
<Button disabled={busy || disabled || !assetId || (!pluginId && !name.trim())} onclick={() => importRelease(false)}>Import only</Button>
</div>
<p>Imports are saved for testing. Choose servers and a test plan before starting a beta.</p>
{/if}
</div>
{/if}
{#if success}
<div class="success" role="status">
<strong>{success.alreadyImported ? 'This binary is already imported.' : 'Release imported.'}</strong>
<a href={`/plugins/${success.pluginId}`}>View plugin</a> · <a href={success.betaUrl}>Start beta</a>
<span class="checksum">SHA-256: {success.sha256}</span>
</div>
{/if}
{#if error}<p class="error" role="alert">{error}</p>{/if}
</section>
</Card>
<style>
.github, .preview { display: grid; gap: var(--space-4); }
.heading, .actions, .update { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.actions { justify-content: flex-start; }
h2, h3, p { margin: 0; }
h2 { font-size: var(--text-md); font-weight: var(--weight-semibold); }
h3 { font-size: var(--text-md); }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.heading p { margin-top: var(--space-2); }
.lookup { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; }
.lookup label { flex: 1 1 300px; }
label { display: grid; gap: var(--space-2); font-size: var(--text-sm); }
input, select { width: 100%; min-width: 0; padding: 10px 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); font: inherit; }
input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.fields { display: grid; gap: var(--space-3); grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); }
.preview { padding-top: var(--space-4); border-top: 1px solid var(--border-subtle); }
a { color: var(--accent-primary-hover); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow-y: auto; font: inherit; font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
summary { cursor: pointer; font-size: var(--text-sm); }
.error { color: var(--state-danger); }
.success { display: block; padding: var(--space-3); background: var(--surface-sunken); border-radius: var(--radius-md); font-size: var(--text-sm); }
.success strong, .checksum { display: block; margin-bottom: var(--space-2); }
.checksum { overflow-wrap: anywhere; font: var(--text-xs) var(--font-mono); margin-top: var(--space-2); }
</style>
@@ -0,0 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PluginUpdate } from '$lib/plugin-github.js';
import Card from './primitives/Card.svelte';
import Button from './primitives/Button.svelte';
let updates = $state<PluginUpdate[]>([]);
let checking = $state(false);
let checked = $state(false);
let error = $state('');
const tracked = $derived(updates.filter(u => u.status !== 'untracked'));
async function check() {
if (checking) return;
checking = true; error = '';
try {
const response = await fetch('/api/plugins/updates');
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not check updates.');
updates = body.updates; checked = true;
} catch (e) { error = (e as Error).message; }
finally { checking = false; }
}
onMount(() => { void check(); });
</script>
<Card padding="lg">
<div class="heading"><h2>Release updates</h2><Button size="sm" loading={checking} onclick={check}>Check for updates</Button></div>
<p>Latest stable GitHub releases. Checks are cached for five minutes.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if checked && !tracked.length}<p>Import a plugin from GitHub to start tracking its releases.</p>{/if}
{#each tracked as update (update.pluginId)}
<div class="row">
<div><a href={`/plugins/${update.pluginId}`}>{update.name}</a>
<p>{update.status === 'available' ? `${update.release?.tag} available` : update.status === 'imported' ? `${update.release?.tag} imported — awaiting promotion` : update.status === 'current' ? `${update.release?.tag} promoted` : update.status === 'ahead' ? 'Ahead of the latest stable release' : update.error}</p>
</div>
{#if update.status === 'available'}<Button size="sm" href={`/plugins/${update.pluginId}#github-releases`}>Review update</Button>{/if}
</div>
{/each}
</Card>
<style>
.heading, .row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
h2 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); }
p { color: var(--text-secondary); font-size: var(--text-sm); margin: var(--space-2) 0 0; }
.row { padding-top: var(--space-3); margin-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
a { color: var(--accent-primary-hover); }
.error { color: var(--state-danger); }
</style>
@@ -0,0 +1,20 @@
import { json } from '@sveltejs/kit';
import { importPluginRelease } from '$lib/server/fleet/plugin-releases.js';
import { GithubReleaseError } from '$lib/server/integrations/github-releases.js';
import { ScriptError } from '$lib/server/fleet/scripts.js';
export async function POST({ request }: { request: Request }) {
let input;
try { input = await request.json(); } catch { return json({ error: 'Invalid JSON body.' }, { status: 400 }); }
if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'Import details are required.' }, { status: 400 });
if ((input.scopeType != null && input.scopeType !== 'game' && input.scopeType !== 'map') ||
(input.scopeValue != null && typeof input.scopeValue !== 'string')) {
return json({ error: 'Invalid plugin scope.' }, { status: 400 });
}
try { return json({ ok: true, ...await importPluginRelease(input) }); }
catch (error) {
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
if (error instanceof ScriptError) return json({ error: error.message }, { status: 409 });
throw error;
}
}
@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import { getGithubRelease, GithubReleaseError } from '$lib/server/integrations/github-releases.js';
export async function GET({ url }: { url: URL }) {
try { return json({ release: await getGithubRelease(url.searchParams.get('url') ?? '') }); }
catch (error) {
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
throw error;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit';
import { listScripts } from '$lib/server/fleet/scripts.js';
import { checkPluginUpdate } from '$lib/server/fleet/plugin-releases.js';
import { GithubReleaseError } from '$lib/server/integrations/github-releases.js';
import type { PluginUpdate } from '$lib/plugin-github.js';
export async function GET({ url }: { url: URL }) {
const raw = url.searchParams.get('pluginId');
if (raw !== null) {
const id = Number(raw);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid plugin id.' }, { status: 400 });
try { return json({ updates: [await checkPluginUpdate(id)] }); }
catch (error) {
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
throw error;
}
}
const plugins = listScripts('plugin');
const updates: PluginUpdate[] = [];
// Bound outbound concurrency for large fleets; repository requests share a five-minute cache.
for (let i = 0; i < plugins.length; i += 4) {
updates.push(...await Promise.all(plugins.slice(i, i + 4).map(p => checkPluginUpdate(p.id))));
}
return json({ updates });
}
+5
View File
@@ -8,6 +8,8 @@ import {
type ScopeType
} from '$lib/server/fleet/scripts.js';
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
import { db } from '$lib/server/db/index.js';
import type { PluginSource } from '$lib/plugin-github.js';
function parseId(idRaw: string): number | null {
const n = Number.parseInt(idRaw, 10);
@@ -27,6 +29,9 @@ export async function GET({ params }: { params: { id: string } }) {
sha256: v.sha256,
size: v.size,
notes: v.notes,
source: script.kind === 'plugin'
? db().prepare('SELECT * FROM plugin_imports WHERE version_id = ?').get(v.id) as PluginSource | undefined ?? null
: null,
state: v.state,
uploaded_at: v.uploaded_at
}));
+21 -1
View File
@@ -82,7 +82,7 @@
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
load();
void load().then(seedImportedPlugin).catch(error => { flash = (error as Error).message; });
poll = setInterval(refreshBetas, 5000);
});
onDestroy(() => {
@@ -102,6 +102,26 @@
return eligibleScripts.filter((s) => !takenIds.has(s.id));
}
async function seedImportedPlugin(): Promise<void> {
const query = new URLSearchParams(window.location.search);
if (!query.has('pluginId')) return;
const pluginId = Number(query.get('pluginId'));
const versionId = Number(query.get('versionId'));
if (!eligibleScripts.some(s => s.id === pluginId && s.kind === 'plugin')) {
flash = 'This plugin is already promoted or has a running beta. Open its detail page to review its status.';
return;
}
picks = [{ key: nextKey++, scriptId: pluginId, versionId: null }];
await onPickScript(0);
if (!versionsForRow(picks[0]).some(v => v.id === versionId)) {
picks[0].versionId = null;
flash = 'The imported version is no longer available for testing. Choose a version to continue.';
return;
}
picks[0].versionId = versionId;
picks = [...picks];
}
async function onPickScript(rowIdx: number): Promise<void> {
flash = null;
const pick = picks[rowIdx];
+6
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import PluginGithubImport from '$ui/components/PluginGithubImport.svelte';
import PluginUpdates from '$ui/components/PluginUpdates.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
@@ -31,6 +33,7 @@
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
let plugins = $state<ArtifactRow[]>([]);
let importCount = $state(0);
let loading = $state(true);
let flash = $state<string | null>(null);
let uploading = $state(false);
@@ -149,6 +152,9 @@
<div class="page">
<SectionHeader title="Plugins" subtitle="Native game plugins deployed alongside scripts." />
<PluginGithubImport onimport={async () => { importCount++; await load(); }} />
{#key importCount}<PluginUpdates />{/key}
<Card padding="lg">
<h2 class="panel-title">Upload a plugin</h2>
{#if successBanner}