Add GitHub plugin release imports and update discovery
This commit is contained in:
@@ -37,6 +37,11 @@ without a Docker health check use their running state as the readiness signal.
|
|||||||
- `scripts/` — legacy JSON importer, fixture seeder
|
- `scripts/` — legacy JSON importer, fixture seeder
|
||||||
- `tests/` — vitest unit tests
|
- `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
|
## How restarts stay safe
|
||||||
|
|
||||||
Betas support configurable gameplay, participation, stability and manual-review
|
Betas support configurable gameplay, participation, stability and manual-review
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
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 (
|
CREATE TABLE IF NOT EXISTS server_scripts (
|
||||||
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
|
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
|
||||||
rel_path TEXT NOT NULL,
|
rel_path TEXT NOT NULL,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
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 { goto } from '$app/navigation';
|
||||||
import Card from './primitives/Card.svelte';
|
import Card from './primitives/Card.svelte';
|
||||||
import Badge from './primitives/Badge.svelte';
|
import Badge from './primitives/Badge.svelte';
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
interface Version {
|
interface Version {
|
||||||
id: number; version: number; sha256: string; size: number;
|
id: number; version: number; sha256: string; size: number;
|
||||||
release_version: string | null;
|
release_version: string | null;
|
||||||
|
source: PluginSource | null;
|
||||||
notes: string | null; state: 'untested' | 'tested' | 'test-failed';
|
notes: string | null; state: 'untested' | 'tested' | 'test-failed';
|
||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
}
|
}
|
||||||
@@ -340,7 +343,9 @@
|
|||||||
<span class="v-num">{artifactVersionLabel(v)}</span>
|
<span class="v-num">{artifactVersionLabel(v)}</span>
|
||||||
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
|
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
|
||||||
<span class="v-size">{v.size} B</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>
|
<span class="v-when">{v.uploaded_at}</span>
|
||||||
{#if a.current_version_id === v.id}
|
{#if a.current_version_id === v.id}
|
||||||
<Badge variant="online" size="sm">Promoted</Badge>
|
<Badge variant="online" size="sm">Promoted</Badge>
|
||||||
@@ -353,6 +358,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</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">
|
<Card padding="lg">
|
||||||
<div class="panel-head"><h2>Danger zone</h2></div>
|
<div class="panel-head"><h2>Danger zone</h2></div>
|
||||||
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
|
<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-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-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 { 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 {
|
.alert {
|
||||||
padding: var(--space-3) var(--space-4);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
type ScopeType
|
type ScopeType
|
||||||
} from '$lib/server/fleet/scripts.js';
|
} from '$lib/server/fleet/scripts.js';
|
||||||
import { findRunningBetaForScript } from '$lib/server/fleet/beta.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 {
|
function parseId(idRaw: string): number | null {
|
||||||
const n = Number.parseInt(idRaw, 10);
|
const n = Number.parseInt(idRaw, 10);
|
||||||
@@ -27,6 +29,9 @@ export async function GET({ params }: { params: { id: string } }) {
|
|||||||
sha256: v.sha256,
|
sha256: v.sha256,
|
||||||
size: v.size,
|
size: v.size,
|
||||||
notes: v.notes,
|
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,
|
state: v.state,
|
||||||
uploaded_at: v.uploaded_at
|
uploaded_at: v.uploaded_at
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
let poll: ReturnType<typeof setInterval> | null = null;
|
let poll: ReturnType<typeof setInterval> | null = null;
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load();
|
void load().then(seedImportedPlugin).catch(error => { flash = (error as Error).message; });
|
||||||
poll = setInterval(refreshBetas, 5000);
|
poll = setInterval(refreshBetas, 5000);
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -102,6 +102,26 @@
|
|||||||
return eligibleScripts.filter((s) => !takenIds.has(s.id));
|
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> {
|
async function onPickScript(rowIdx: number): Promise<void> {
|
||||||
flash = null;
|
flash = null;
|
||||||
const pick = picks[rowIdx];
|
const pick = picks[rowIdx];
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||||
import Card from '$ui/components/primitives/Card.svelte';
|
import Card from '$ui/components/primitives/Card.svelte';
|
||||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
||||||
|
|
||||||
let plugins = $state<ArtifactRow[]>([]);
|
let plugins = $state<ArtifactRow[]>([]);
|
||||||
|
let importCount = $state(0);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let flash = $state<string | null>(null);
|
let flash = $state<string | null>(null);
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
@@ -149,6 +152,9 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Plugins" subtitle="Native game plugins deployed alongside scripts." />
|
<SectionHeader title="Plugins" subtitle="Native game plugins deployed alongside scripts." />
|
||||||
|
|
||||||
|
<PluginGithubImport onimport={async () => { importCount++; await load(); }} />
|
||||||
|
{#key importCount}<PluginUpdates />{/key}
|
||||||
|
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<h2 class="panel-title">Upload a plugin</h2>
|
<h2 class="panel-title">Upload a plugin</h2>
|
||||||
{#if successBanner}
|
{#if successBanner}
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user