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
+5
View File
@@ -37,6 +37,11 @@ without a Docker health check use their running state as the readiness signal.
- `scripts/` — legacy JSON importer, fixture seeder
- `tests/` — vitest unit tests
## Plugin releases
Plugins support named release versions, GitHub DLL imports and update discovery.
See [Plugin releases](docs/plugin-releases.md) for the import and beta workflow.
## How restarts stay safe
Betas support configurable gameplay, participation, stability and manual-review
+61
View File
@@ -0,0 +1,61 @@
# Plugin releases from GitHub
On **Plugins**, paste a public GitHub repository or release URL into **Import
from GitHub**. Repository and `/releases/latest` URLs preview the latest stable
release. Specific `/releases/tag/v3.1.0` links and
`/releases#release-v3.1.0` links select that exact release, including prereleases.
Review the notes, select a DLL, give the plugin a name, and choose an optional
T5/T6 game scope. Map scopes can be set on the plugin detail page. **Import only**
saves the binary; **Import and test** opens the beta builder with that exact
plugin version selected. Select servers and a test plan to start testing.
Imports alone never change server files or promote versions.
Each import stores the publisher's tag, release notes, GitHub repository,
release URL, release and asset IDs, asset filename, publication date and a
SHA-256 checksum. If GitHub supplies a SHA-256 asset digest, the download must
match it. The existing upload number still distinguishes different builds.
Version history links back to each imported release.
## Discovering updates
The plugins page and each plugin's GitHub panel check for the latest stable
release. Lookups are cached per repository for five minutes, including when
using **Check for updates**. No background upgrades or automatic promotions
occur. Failed checks are shown per plugin so one unavailable repository does
not hide other updates.
**Review update** opens release notes and a DLL picker. The previously imported
asset filename is selected when available. Multiple unfamiliar DLLs require a
selection. Importing into an existing plugin keeps its scope and deployment
filename. An active beta must be completed before another release is imported.
Update tracking follows the most recently imported version's repository and
asset name. An imported release is distinguished from a promoted release;
importing the same release asset and binary into that plugin again reuses the
existing version. An older stable release is not offered as an upgrade over a
newer imported prerelease. A plugin uploaded manually starts tracking once a
GitHub release is imported from its detail page.
## Limits
- Public repositories on github.com only; no GitHub credentials are needed.
- Direct `.dll` release assets up to 64 MB; archives and source-code downloads
are not extracted. Other formats can still use the manual upload workflow.
- Release tags have the same 80-character limit as manually entered versions.
- GitHub rate limits, missing releases, download failures and checksum failures
produce errors without saving a partial import.
- Downloads follow only HTTPS redirects to GitHub's release asset hosts and
have a size limit and timeout.
## API
- `GET /api/plugins/github/release?url=...` previews a release and its DLL assets.
- `POST /api/plugins/github/import` accepts JSON `{ url, assetId, name,
scopeType?, scopeValue? }` for a new plugin, or `{ url, assetId, pluginId }`
for an existing plugin. Use the preview's exact release URL, not a mutable
`latest` URL. Returns plugin/version IDs, checksum and the beta builder URL.
- `GET /api/plugins/updates` checks all plugins; `?pluginId=123` checks one.
The server resolves release metadata again during import. Client-supplied notes,
tags and download URLs are not used as provenance or download instructions.
+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}
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createHash } from 'node:crypto';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const content = Buffer.from([0x4d, 0x5a, 1, 2, 3, 4]);
const sha256 = createHash('sha256').update(content).digest('hex');
const url = 'https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0';
const metadata = (overrides: Record<string, unknown> = {}) => ({
id: 100, tag_name: 'v3.1.0', name: 'Utilities 3.1', body: 'Release notes <script>untrusted</script>',
published_at: '2026-07-27T12:00:00Z', draft: false, prerelease: false,
assets: [{ id: 200, name: 't6-gsc-utils.dll', size: content.length, digest: `sha256:${sha256}`, state: 'uploaded' }],
...overrides
});
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
delete process.env.KOMODO_ADDRESS;
delete process.env.RCON_PASSWORD;
fetchMock = vi.fn(async (input: string | URL) => String(input).startsWith('https://api.github.com/')
? Response.json(metadata()) : new Response(content));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(async () => { vi.unstubAllGlobals(); await fixture.cleanup(); });
describe('GitHub release lookup', () => {
it('supports repository, latest, tag and the supplied release-anchor URLs', async () => {
const { parseGithubReleaseUrl, getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js');
expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils/releases#release-v3.1.0')).toEqual({ repository: 'alicealys/t6-gsc-utils', tag: 'v3.1.0' });
expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils/releases/latest').tag).toBeNull();
expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils').tag).toBeNull();
expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils/releases/tag/release%2F3.1').tag).toBe('release/3.1');
const release = await getGithubRelease(url);
expect(release).toMatchObject({ repository: 'alicealys/t6-gsc-utils', tag: 'v3.1.0', assets: [{ name: 't6-gsc-utils.dll' }] });
expect(fetchMock.mock.calls[0][0]).toBe('https://api.github.com/repos/alicealys/t6-gsc-utils/releases/tags/v3.1.0');
});
it.each(['http://github.com/a/b', 'https://github.com.evil.test/a/b', 'https://github.com@127.0.0.1/a/b',
'https://github.com:444/a/b', 'https://github.com/a/b/issues/1', 'https://github.com/a/b/releases#unknown',
'https://github.com/a/b/releases/tag/', 'https://github.com/a/b/releases/tag/v1%0A'])('rejects unsupported URL %s without fetching it', async (input) => {
const { getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js');
await expect(getGithubRelease(input)).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
it('filters out archives, unsafe filenames and oversized assets; shares concurrent lookups', async () => {
const normal = metadata().assets[0];
fetchMock.mockImplementation(async () => Response.json(metadata({ assets: [normal,
{ ...normal, id: 201, name: 'source.zip' }, { ...normal, id: 202, name: '../evil.dll' },
{ ...normal, id: 203, size: 65 * 1024 * 1024 }, { ...normal, id: 204, state: 'new' }] })));
const { getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js');
const [first, second] = await Promise.all([getGithubRelease(url), getGithubRelease(url)]);
expect(first.assets).toHaveLength(1);
expect(second).toEqual(first);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('reports rate limits and retries failed lookups', async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 }));
const { getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js');
await expect(getGithubRelease(url)).rejects.toMatchObject({ status: 429 });
await expect(getGithubRelease(url)).resolves.toMatchObject({ tag: 'v3.1.0' });
});
});
describe('GitHub DLL imports', () => {
it('captures provenance and notes, leaves promotion untouched, and deduplicates concurrent imports', async () => {
const { importPluginRelease, pluginSource } = await import('../../src/lib/server/fleet/plugin-releases.js');
const { getScript, getVersion, listVersions } = await import('../../src/lib/server/fleet/scripts.js');
const imported = await importPluginRelease({ url, assetId: 200, name: 'Utilities', scopeType: 'game', scopeValue: 't6' });
expect(imported.sha256).toBe(sha256);
expect(imported.betaUrl).toBe(`/betas?pluginId=${imported.pluginId}&versionId=${imported.versionId}`);
expect(getScript(imported.pluginId)).toMatchObject({ kind: 'plugin', target_rel_path: 't6-gsc-utils.dll', scope_value: 't6', current_version_id: null });
expect(getVersion(imported.versionId)).toMatchObject({ release_version: 'v3.1.0', content, notes: metadata().body, state: 'untested' });
expect(pluginSource(imported.pluginId)).toMatchObject({ repository: 'alicealys/t6-gsc-utils', release_id: 100, asset_id: 200, release_url: url });
const duplicates = await Promise.all([1, 2].map(() => importPluginRelease({ url, assetId: 200, pluginId: imported.pluginId })));
expect(duplicates.every(r => r.alreadyImported && r.versionId === imported.versionId)).toBe(true);
expect(listVersions(imported.pluginId)).toHaveLength(1);
const { GET } = await import('../../src/routes/api/scripts/[id]/+server.js');
const response = await (await GET({ params: { id: String(imported.pluginId) } })).json();
expect(response.versions[0].source.asset_id).toBe(200);
expect(response.versions[0]).not.toHaveProperty('content');
});
it('follows GitHub asset redirects and verifies the bytes', async () => {
fetchMock.mockImplementation(async (input) => {
if (String(input).startsWith('https://api.github.com/')) return Response.json(metadata());
if (String(input).startsWith('https://github.com/')) return new Response(null, { status: 302, headers: { location: 'https://release-assets.githubusercontent.com/blob/test' } });
return new Response(content);
});
const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js');
await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).resolves.toMatchObject({ sha256 });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it.each(['http://127.0.0.1/internal', 'https://evil.test/file.dll', 'https://user:pass@github.com/file.dll'])('blocks unsafe download redirects to %s', async (location) => {
fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/')
? Response.json(metadata()) : new Response(null, { status: 302, headers: { location } }));
const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js');
const { listScripts } = await import('../../src/lib/server/fleet/scripts.js');
await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).rejects.toThrow(/unsupported host/);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(listScripts()).toHaveLength(0);
});
it('rejects checksum mismatches and truncated downloads without creating a plugin', async () => {
const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js');
const { listScripts } = await import('../../src/lib/server/fleet/scripts.js');
fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/')
? Response.json(metadata()) : new Response(Buffer.alloc(content.length)));
await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).rejects.toThrow(/checksum/);
fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/')
? Response.json(metadata()) : new Response(Buffer.alloc(1)));
await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).rejects.toThrow(/size differs/);
expect(listScripts()).toHaveLength(0);
});
it('rejects a missing asset and refuses to import into non-plugins or active betas', async () => {
const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
await expect(importPluginRelease({ url, assetId: 999, name: 'Utilities' })).rejects.toThrow(/no longer available/);
const script = createScript({ name: 'Script', targetRelPath: 's.gsc', content });
await expect(importPluginRelease({ url, assetId: 200, pluginId: script.script.id })).rejects.toMatchObject({ status: 404 });
const plugin = createScript({ name: 'Plugin', kind: 'plugin', targetRelPath: 'p.dll', content });
const beta = db().prepare("INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'version', 'running')")
.run(plugin.script.id, plugin.version.id);
db().prepare('INSERT INTO beta_scripts (beta_id, script_id, script_version_id, kind) VALUES (?, ?, ?, ?)')
.run(beta.lastInsertRowid, plugin.script.id, plugin.version.id, 'plugin');
await expect(importPluginRelease({ url, assetId: 200, pluginId: plugin.script.id })).rejects.toMatchObject({ status: 409 });
});
});
describe('plugin update discovery', () => {
it('finds newer stable releases, imports into the same plugin, and distinguishes imported from promoted', async () => {
const { importPluginRelease, checkPluginUpdate } = await import('../../src/lib/server/fleet/plugin-releases.js');
const { getScript, listVersions } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const first = await importPluginRelease({ url, assetId: 200, name: 'Utilities' });
const next = metadata({ id: 101, tag_name: 'v3.1.1', published_at: '2026-08-11T12:00:00Z',
assets: [{ ...metadata().assets[0], id: 201 }] });
fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') ? Response.json(next) : new Response(content));
const update = await checkPluginUpdate(first.pluginId);
expect(update).toMatchObject({ status: 'available', release: { tag: 'v3.1.1' } });
const second = await importPluginRelease({ url: update.release!.url, assetId: 201, pluginId: first.pluginId });
expect(listVersions(first.pluginId)).toHaveLength(2);
expect(getScript(first.pluginId)!.current_version_id).toBeNull();
expect(await checkPluginUpdate(first.pluginId)).toMatchObject({ status: 'imported', versionId: second.versionId });
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(second.versionId, first.pluginId);
expect(await checkPluginUpdate(first.pluginId)).toMatchObject({ status: 'current', versionId: second.versionId });
});
it('does not recommend an older stable release after importing a newer prerelease', async () => {
const { importPluginRelease, checkPluginUpdate } = await import('../../src/lib/server/fleet/plugin-releases.js');
const stable = await importPluginRelease({ url, assetId: 200, name: 'Utilities' });
fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') ? Response.json(metadata({ id: 105, tag_name: 'v4.0-rc1', prerelease: true, published_at: '2026-09-01T12:00:00Z' })) : new Response(content));
const imported = await importPluginRelease({ url: url.replace('v3.1.0', 'v4.0-rc1'), assetId: 200, pluginId: stable.pluginId });
fetchMock.mockImplementation(async () => Response.json(metadata()));
expect(await checkPluginUpdate(imported.pluginId)).toMatchObject({ status: 'ahead' });
});
it('reports untracked and GitHub failures per plugin without breaking the list', async () => {
const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
await importPluginRelease({ url, assetId: 200, name: 'Imported' });
createScript({ name: 'Manual', kind: 'plugin', targetRelPath: 'manual.dll', content });
fetchMock.mockResolvedValue(new Response(null, { status: 404 }));
const { GET } = await import('../../src/routes/api/plugins/updates/+server.js');
const result = await (await GET({ url: new URL('http://localhost/api/plugins/updates') })).json();
expect(result.updates.map((u: { status: string }) => u.status)).toEqual(['error', 'untracked']);
});
it('serves the preview and import endpoints and validates bad requests', async () => {
const { GET } = await import('../../src/routes/api/plugins/github/release/+server.js');
const { POST } = await import('../../src/routes/api/plugins/github/import/+server.js');
const preview = await GET({ url: new URL(`http://localhost/api/plugins/github/release?url=${encodeURIComponent(url)}`) });
expect((await preview.json()).release.assets[0].id).toBe(200);
const request = (body: unknown) => new Request('http://localhost/api/plugins/github/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
expect((await POST({ request: request(null) })).status).toBe(400);
expect((await POST({ request: request({ url, assetId: 200, name: 'Test', scopeType: 'other' }) })).status).toBe(400);
const response = await POST({ request: request({ url, assetId: 200, name: 'Test' }) });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true, sha256, releaseVersion: 'v3.1.0' });
});
});