Add publisher release versions to plugins
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
/** Keep the upload number visible: two builds may share a publisher's release tag. */
|
||||||
|
export function artifactVersionLabel(version: { version: number; release_version?: string | null } | null | undefined): string {
|
||||||
|
if (!version) return '—';
|
||||||
|
return version.release_version
|
||||||
|
? `${version.release_version} (upload ${version.version})`
|
||||||
|
: `v${version.version}`;
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ const SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||||
|
{ name: 'release_version', ddl: 'ALTER TABLE script_versions ADD COLUMN release_version TEXT' },
|
||||||
// state was added post-launch to make untested/tested/test-failed visible
|
// state was added post-launch to make untested/tested/test-failed visible
|
||||||
// in the UI. Existing rows default to 'untested' — this is safe because
|
// in the UI. Existing rows default to 'untested' — this is safe because
|
||||||
// any real promoted version's script has current_version_id set (which is
|
// any real promoted version's script has current_version_id set (which is
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS script_versions (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||||
version INTEGER NOT NULL,
|
version INTEGER NOT NULL,
|
||||||
|
release_version TEXT, -- publisher's version/tag, independent of upload order
|
||||||
sha256 TEXT NOT NULL,
|
sha256 TEXT NOT NULL,
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
content BLOB NOT NULL,
|
content BLOB NOT NULL,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface ScriptVersionRow {
|
|||||||
id: number;
|
id: number;
|
||||||
script_id: number;
|
script_id: number;
|
||||||
version: number;
|
version: number;
|
||||||
|
release_version: string | null;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
size: number;
|
size: number;
|
||||||
content: Buffer;
|
content: Buffer;
|
||||||
@@ -66,6 +67,7 @@ export class ScriptError extends Error {
|
|||||||
| 'baseline-requires-scope'
|
| 'baseline-requires-scope'
|
||||||
| 'baseline-exists'
|
| 'baseline-exists'
|
||||||
| 'empty-overlay'
|
| 'empty-overlay'
|
||||||
|
| 'invalid-release-version'
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
@@ -75,6 +77,14 @@ function sha256(buf: Buffer): string {
|
|||||||
return createHash('sha256').update(buf).digest('hex');
|
return createHash('sha256').update(buf).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReleaseVersion(value: string | null | undefined): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (typeof value !== 'string' || value.length > 80 || /[\u0000-\u001f\u007f]/.test(value)) {
|
||||||
|
throw new ScriptError('release version must be a single line of at most 80 characters', 'invalid-release-version');
|
||||||
|
}
|
||||||
|
return value.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getScript(id: number): ScriptRow | undefined {
|
export function getScript(id: number): ScriptRow | undefined {
|
||||||
return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as ScriptRow | undefined;
|
return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as ScriptRow | undefined;
|
||||||
}
|
}
|
||||||
@@ -157,6 +167,8 @@ export interface CreateScriptInput {
|
|||||||
scopeValue?: string | null;
|
scopeValue?: string | null;
|
||||||
content: Buffer;
|
content: Buffer;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
/** Publisher's release tag, e.g. v3.1.0. Upload numbering stays independent. */
|
||||||
|
releaseVersion?: string;
|
||||||
/** Defaults to 'script' for back-compat with existing callers. */
|
/** Defaults to 'script' for back-compat with existing callers. */
|
||||||
kind?: ArtifactKind;
|
kind?: ArtifactKind;
|
||||||
}
|
}
|
||||||
@@ -206,6 +218,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
|
|||||||
if (getScriptByName(input.name)) throw new ScriptError(`script "${input.name}" already exists`, 'name-taken');
|
if (getScriptByName(input.name)) throw new ScriptError(`script "${input.name}" already exists`, 'name-taken');
|
||||||
|
|
||||||
const sha = sha256(input.content);
|
const sha = sha256(input.content);
|
||||||
|
const releaseVersion = normalizeReleaseVersion(input.releaseVersion);
|
||||||
const size = input.content.byteLength;
|
const size = input.content.byteLength;
|
||||||
|
|
||||||
const insertScript = db().prepare(
|
const insertScript = db().prepare(
|
||||||
@@ -213,8 +226,8 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
|
|||||||
VALUES (?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
);
|
);
|
||||||
const insertVersion = db().prepare(
|
const insertVersion = db().prepare(
|
||||||
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind)
|
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind, release_version)
|
||||||
VALUES (?, 1, ?, ?, ?, ?, ?)`
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?)`
|
||||||
);
|
);
|
||||||
|
|
||||||
let scriptId!: number;
|
let scriptId!: number;
|
||||||
@@ -222,7 +235,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
|
|||||||
db().transaction(() => {
|
db().transaction(() => {
|
||||||
const s = insertScript.run(input.name, targetRelPath, input.scopeType ?? null, input.scopeValue ?? null, kind);
|
const s = insertScript.run(input.name, targetRelPath, input.scopeType ?? null, input.scopeValue ?? null, kind);
|
||||||
scriptId = Number(s.lastInsertRowid);
|
scriptId = Number(s.lastInsertRowid);
|
||||||
const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null, kind);
|
const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null, kind, releaseVersion);
|
||||||
versionId = Number(v.lastInsertRowid);
|
versionId = Number(v.lastInsertRowid);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -232,7 +245,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadNewVersion(scriptId: number, content: Buffer, notes?: string): ScriptVersionRow {
|
export function uploadNewVersion(scriptId: number, content: Buffer, notes?: string, releaseVersion?: string): ScriptVersionRow {
|
||||||
const script = getScript(scriptId);
|
const script = getScript(scriptId);
|
||||||
if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script');
|
if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script');
|
||||||
|
|
||||||
@@ -249,13 +262,14 @@ export function uploadNewVersion(scriptId: number, content: Buffer, notes?: stri
|
|||||||
const latest = getLatestVersion(scriptId);
|
const latest = getLatestVersion(scriptId);
|
||||||
const nextVersion = (latest?.version ?? 0) + 1;
|
const nextVersion = (latest?.version ?? 0) + 1;
|
||||||
const sha = sha256(content);
|
const sha = sha256(content);
|
||||||
|
const release = normalizeReleaseVersion(releaseVersion);
|
||||||
|
|
||||||
const res = db()
|
const res = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind)
|
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind, release_version)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null, script.kind);
|
.run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null, script.kind, release);
|
||||||
return getVersion(Number(res.lastInsertRowid))!;
|
return getVersion(Number(res.lastInsertRowid))!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { artifactVersionLabel } from '$lib/artifact-version.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';
|
||||||
@@ -28,6 +29,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;
|
||||||
notes: string | null; state: 'untested' | 'tested' | 'test-failed';
|
notes: string | null; state: 'untested' | 'tested' | 'test-failed';
|
||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
|
|
||||||
let versionFile = $state<File | null>(null);
|
let versionFile = $state<File | null>(null);
|
||||||
let versionNotes = $state('');
|
let versionNotes = $state('');
|
||||||
|
let releaseVersion = $state('');
|
||||||
let uploadingVersion = $state(false);
|
let uploadingVersion = $state(false);
|
||||||
|
|
||||||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||||||
@@ -143,6 +146,7 @@
|
|||||||
}
|
}
|
||||||
function openUploadModal(): void {
|
function openUploadModal(): void {
|
||||||
if (runningBetaId !== null) return;
|
if (runningBetaId !== null) return;
|
||||||
|
releaseVersion = '';
|
||||||
versionFile = null;
|
versionFile = null;
|
||||||
versionNotes = '';
|
versionNotes = '';
|
||||||
flash = null;
|
flash = null;
|
||||||
@@ -169,6 +173,7 @@
|
|||||||
uploadingVersion = true; flash = null;
|
uploadingVersion = true; flash = null;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('content', versionFile);
|
form.append('content', versionFile);
|
||||||
|
if (family === 'plugin') form.append('releaseVersion', releaseVersion.trim());
|
||||||
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
|
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||||||
@@ -302,7 +307,7 @@
|
|||||||
<span class="k">Current version</span>
|
<span class="k">Current version</span>
|
||||||
<span class="v mono">
|
<span class="v mono">
|
||||||
{#if currentVersion}
|
{#if currentVersion}
|
||||||
v{currentVersion.version} · {currentVersion.sha256.slice(0, 8)}
|
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(0, 8)}
|
||||||
<Badge variant="online" size="sm">Promoted</Badge>
|
<Badge variant="online" size="sm">Promoted</Badge>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="dim">Never promoted — open a <a href="/betas">beta</a></span>
|
<span class="dim">Never promoted — open a <a href="/betas">beta</a></span>
|
||||||
@@ -313,7 +318,7 @@
|
|||||||
<div class="kv">
|
<div class="kv">
|
||||||
<span class="k">Latest version</span>
|
<span class="k">Latest version</span>
|
||||||
<span class="v mono">
|
<span class="v mono">
|
||||||
v{latestVersion.version} · {latestVersion.sha256.slice(0, 8)}
|
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
|
||||||
<Badge variant={stateVariant(latestVersion.state)} size="sm">{latestVersion.state}</Badge>
|
<Badge variant={stateVariant(latestVersion.state)} size="sm">{latestVersion.state}</Badge>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,7 +337,7 @@
|
|||||||
<div class="version-list">
|
<div class="version-list">
|
||||||
{#each versions as v (v.id)}
|
{#each versions as v (v.id)}
|
||||||
<div class="version-row" class:current={a.current_version_id === v.id}>
|
<div class="version-row" class:current={a.current_version_id === v.id}>
|
||||||
<span class="v-num">v{v.version}</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 ?? ''}</span>
|
||||||
@@ -391,8 +396,13 @@
|
|||||||
<button type="button" class="close" onclick={cancelUploadModal} aria-label="Close">×</button>
|
<button type="button" class="close" onclick={cancelUploadModal} aria-label="Close">×</button>
|
||||||
</header>
|
</header>
|
||||||
<Field label="File">
|
<Field label="File">
|
||||||
<Input type="file" onchange={pickVersionFile} disabled={uploadingVersion} />
|
<Input type="file" accept={family === 'plugin' ? '.dll' : undefined} onchange={pickVersionFile} disabled={uploadingVersion} />
|
||||||
</Field>
|
</Field>
|
||||||
|
{#if family === 'plugin'}
|
||||||
|
<Field label="Release version" hint="Optional. Use the publisher’s version or release tag, such as v3.1.0.">
|
||||||
|
<Input bind:value={releaseVersion} placeholder="v3.1.0" disabled={uploadingVersion} />
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
<Field label="Notes" hint="Optional. What changed since the previous version.">
|
<Field label="Notes" hint="Optional. What changed since the previous version.">
|
||||||
<Input bind:value={versionNotes} placeholder="what changed" disabled={uploadingVersion} />
|
<Input bind:value={versionNotes} placeholder="what changed" disabled={uploadingVersion} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -473,7 +483,7 @@
|
|||||||
.version-list { display: flex; flex-direction: column; gap: var(--space-1); }
|
.version-list { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||||
.version-row {
|
.version-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 130px 80px 1fr 220px auto;
|
grid-template-columns: minmax(100px, 1fr) 130px 80px 1fr 220px auto;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
@@ -487,7 +497,7 @@
|
|||||||
border-color: rgba(124, 212, 255, 0.35);
|
border-color: rgba(124, 212, 255, 0.35);
|
||||||
background: rgba(124, 212, 255, 0.04);
|
background: rgba(124, 212, 255, 0.04);
|
||||||
}
|
}
|
||||||
.v-num { color: var(--text-primary); font-weight: var(--weight-semibold); }
|
.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); }
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function GET({ url }: { url: URL }) {
|
|||||||
target_rel_path: script?.target_rel_path ?? '',
|
target_rel_path: script?.target_rel_path ?? '',
|
||||||
kind: script?.kind ?? bs.kind ?? 'script',
|
kind: script?.kind ?? bs.kind ?? 'script',
|
||||||
version: version
|
version: version
|
||||||
? { id: version.id, version: version.version, sha256: version.sha256 }
|
? { id: version.id, version: version.version, release_version: version.release_version, sha256: version.sha256 }
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function GET({ params }: { params: { id: string } }) {
|
|||||||
target_rel_path: script?.target_rel_path ?? '',
|
target_rel_path: script?.target_rel_path ?? '',
|
||||||
kind: script?.kind ?? bs.kind ?? 'script',
|
kind: script?.kind ?? bs.kind ?? 'script',
|
||||||
version: version
|
version: version
|
||||||
? { id: version.id, version: version.version, sha256: version.sha256 }
|
? { id: version.id, version: version.version, release_version: version.release_version, sha256: version.sha256 }
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function GET({ url }: { url: URL }) {
|
|||||||
? {
|
? {
|
||||||
id: versions[0].id,
|
id: versions[0].id,
|
||||||
version: versions[0].version,
|
version: versions[0].version,
|
||||||
|
release_version: versions[0].release_version,
|
||||||
sha256: versions[0].sha256,
|
sha256: versions[0].sha256,
|
||||||
size: versions[0].size,
|
size: versions[0].size,
|
||||||
state: versions[0].state,
|
state: versions[0].state,
|
||||||
@@ -58,7 +59,7 @@ export async function GET({ url }: { url: URL }) {
|
|||||||
|
|
||||||
// Multipart POST — creates an artifact + version 1 from either an uploaded
|
// Multipart POST — creates an artifact + version 1 from either an uploaded
|
||||||
// file (script/plugin/baseline) or a text body (config overlay). Fields:
|
// file (script/plugin/baseline) or a text body (config overlay). Fields:
|
||||||
// name, scopeType, scopeValue, notes, kind (defaults to 'script'),
|
// name, scopeType, scopeValue, notes, releaseVersion, kind (defaults to 'script'),
|
||||||
// content (file). Configs may alternatively send `text` as a form field.
|
// content (file). Configs may alternatively send `text` as a form field.
|
||||||
export async function POST({ request, url }: { request: Request; url: URL }) {
|
export async function POST({ request, url }: { request: Request; url: URL }) {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
@@ -104,6 +105,7 @@ export async function POST({ request, url }: { request: Request; url: URL }) {
|
|||||||
scopeValue,
|
scopeValue,
|
||||||
content,
|
content,
|
||||||
notes,
|
notes,
|
||||||
|
releaseVersion: form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined,
|
||||||
kind
|
kind
|
||||||
});
|
});
|
||||||
return json({ ok: true, script, versionId: version.id });
|
return json({ ok: true, script, versionId: version.id });
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function GET({ params }: { params: { id: string } }) {
|
|||||||
const versions = listVersions(id).map((v) => ({
|
const versions = listVersions(id).map((v) => ({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
version: v.version,
|
version: v.version,
|
||||||
|
release_version: v.release_version,
|
||||||
sha256: v.sha256,
|
sha256: v.sha256,
|
||||||
size: v.size,
|
size: v.size,
|
||||||
notes: v.notes,
|
notes: v.notes,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function parseId(idRaw: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Multipart POST — creates a new version from an uploaded file. Fields:
|
// Multipart POST — creates a new version from an uploaded file. Fields:
|
||||||
// content (file), notes (optional). Response includes a "betaRequired" hint
|
// content (file), notes and releaseVersion (optional). Response includes a "betaRequired" hint
|
||||||
// when the script has a scope so the UI can nudge the operator to open a beta.
|
// when the script has a scope so the UI can nudge the operator to open a beta.
|
||||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||||
const scriptId = parseId(params.id);
|
const scriptId = parseId(params.id);
|
||||||
@@ -22,12 +22,14 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
|||||||
|
|
||||||
const content = Buffer.from(await file.arrayBuffer());
|
const content = Buffer.from(await file.arrayBuffer());
|
||||||
try {
|
try {
|
||||||
const version = uploadNewVersion(scriptId, content, notes);
|
const releaseVersion = form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined;
|
||||||
|
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
|
||||||
return json({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
version: {
|
version: {
|
||||||
id: version.id,
|
id: version.id,
|
||||||
version: version.version,
|
version: version.version,
|
||||||
|
release_version: version.release_version,
|
||||||
sha256: version.sha256,
|
sha256: version.sha256,
|
||||||
size: version.size,
|
size: version.size,
|
||||||
notes: version.notes,
|
notes: version.notes,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import BetaPlanBuilder from "$ui/components/BetaPlanBuilder.svelte";
|
import BetaPlanBuilder from "$ui/components/BetaPlanBuilder.svelte";
|
||||||
import { defaultPlan, parseBetaPlan } from "$lib/beta-plan.js";
|
import { defaultPlan, parseBetaPlan } from "$lib/beta-plan.js";
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
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';
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
closed_at: string | null;
|
closed_at: string | null;
|
||||||
scripts: Array<{
|
scripts: Array<{
|
||||||
id: number; name: string; target_rel_path: string; kind: ArtifactKind;
|
id: number; name: string; target_rel_path: string; kind: ArtifactKind;
|
||||||
version: { id: number; version: number; sha256: string } | null;
|
version: { id: number; version: number; release_version: string | null; sha256: string } | null;
|
||||||
}>;
|
}>;
|
||||||
servers: Array<{ server: string; stage: string; error: string | null }>;
|
servers: Array<{ server: string; stage: string; error: string | null }>;
|
||||||
}
|
}
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
} | null;
|
} | null;
|
||||||
runningBetaId: number | null;
|
runningBetaId: number | null;
|
||||||
}
|
}
|
||||||
interface Version { id: number; version: number; sha256: string; size: number; state: 'untested' | 'tested' | 'test-failed'; }
|
interface Version { id: number; version: number; release_version: string | null; sha256: string; size: number; state: 'untested' | 'tested' | 'test-failed'; }
|
||||||
interface ServerRow { name: string; game: string | null; map: string | null; }
|
interface ServerRow { name: string; game: string | null; map: string | null; }
|
||||||
interface ScriptDetail { script: ScriptListRow; versions: Version[]; matchedServers: string[]; }
|
interface ScriptDetail { script: ScriptListRow; versions: Version[]; matchedServers: string[]; }
|
||||||
interface Pick { key: number; scriptId: number | null; versionId: number | null; }
|
interface Pick { key: number; scriptId: number | null; versionId: number | null; }
|
||||||
@@ -205,7 +206,7 @@
|
|||||||
if (b.scripts.length === 0) return '(no artifacts)';
|
if (b.scripts.length === 0) return '(no artifacts)';
|
||||||
if (b.scripts.length === 1) {
|
if (b.scripts.length === 1) {
|
||||||
const s = b.scripts[0];
|
const s = b.scripts[0];
|
||||||
return `[${s.kind}] ${s.name} · v${s.version?.version ?? '?'}`;
|
return `[${s.kind}] ${s.name} · ${artifactVersionLabel(s.version)}`;
|
||||||
}
|
}
|
||||||
const byKind: Record<string, number> = {};
|
const byKind: Record<string, number> = {};
|
||||||
for (const s of b.scripts) byKind[s.kind] = (byKind[s.kind] ?? 0) + 1;
|
for (const s of b.scripts) byKind[s.kind] = (byKind[s.kind] ?? 0) + 1;
|
||||||
@@ -265,7 +266,7 @@
|
|||||||
<option value={null}>—</option>
|
<option value={null}>—</option>
|
||||||
{:else}
|
{:else}
|
||||||
{#each versionsForRow(row) as v (v.id)}
|
{#each versionsForRow(row) as v (v.id)}
|
||||||
<option value={v.id}>v{v.version} · {v.sha256.slice(0, 8)} · {v.state}</option>
|
<option value={v.id}>{artifactVersionLabel(v)} · {v.sha256.slice(0, 8)} · {v.state}</option>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||||
import BetaTestProgress from "$ui/components/BetaTestProgress.svelte";
|
import BetaTestProgress from "$ui/components/BetaTestProgress.svelte";
|
||||||
import type { BetaTestView } from "$lib/beta-plan.js";
|
import type { BetaTestView } from "$lib/beta-plan.js";
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
interface BetaScript {
|
interface BetaScript {
|
||||||
id: number; name: string; target_rel_path: string;
|
id: number; name: string; target_rel_path: string;
|
||||||
kind: 'script' | 'config' | 'plugin' | 'baseline';
|
kind: 'script' | 'config' | 'plugin' | 'baseline';
|
||||||
version: { id: number; version: number; sha256: string } | null;
|
version: { id: number; version: number; release_version: string | null; sha256: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let test = $state<BetaTestView | null>(null);
|
let test = $state<BetaTestView | null>(null);
|
||||||
@@ -164,7 +165,7 @@
|
|||||||
<Badge variant="neutral" size="sm" uppercase>{s.kind}</Badge>
|
<Badge variant="neutral" size="sm" uppercase>{s.kind}</Badge>
|
||||||
<a class="s-name" href="/scripts/{s.id}">{s.name}</a>
|
<a class="s-name" href="/scripts/{s.id}">{s.name}</a>
|
||||||
<span class="s-target">{s.target_rel_path}</span>
|
<span class="s-target">{s.target_rel_path}</span>
|
||||||
<span class="s-ver">v{s.version?.version ?? '?'}</span>
|
<span class="s-ver">{artifactVersionLabel(s.version)}</span>
|
||||||
<span class="s-sha">{s.version?.sha256.slice(0, 8) ?? ''}</span>
|
<span class="s-sha">{s.version?.sha256.slice(0, 8) ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
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';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
kind: ArtifactKind;
|
kind: ArtifactKind;
|
||||||
latestVersion: {
|
latestVersion: {
|
||||||
id: number; version: number; sha256: string; size: number;
|
id: number; version: number; sha256: string; size: number;
|
||||||
|
release_version: string | null;
|
||||||
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
|
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
|
||||||
} | null;
|
} | null;
|
||||||
versionCount: number;
|
versionCount: number;
|
||||||
@@ -33,9 +35,10 @@
|
|||||||
let flash = $state<string | null>(null);
|
let flash = $state<string | null>(null);
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
||||||
let successBanner = $state<{ name: string; artifactId: number; version: number } | null>(null);
|
let successBanner = $state<{ name: string; artifactId: number; version: number; release_version: string | null } | null>(null);
|
||||||
|
|
||||||
let newName = $state('');
|
let newName = $state('');
|
||||||
|
let newReleaseVersion = $state('');
|
||||||
let newScopeType = $state<'none' | 'game' | 'map'>('none');
|
let newScopeType = $state<'none' | 'game' | 'map'>('none');
|
||||||
let newScopeGame = $state('');
|
let newScopeGame = $state('');
|
||||||
let newScopeMap = $state('');
|
let newScopeMap = $state('');
|
||||||
@@ -85,6 +88,7 @@
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('name', newName.trim());
|
form.append('name', newName.trim());
|
||||||
form.append('kind', 'plugin');
|
form.append('kind', 'plugin');
|
||||||
|
form.append('releaseVersion', newReleaseVersion.trim());
|
||||||
form.append('scopeType', newScopeType);
|
form.append('scopeType', newScopeType);
|
||||||
if (scopeValue) form.append('scopeValue', scopeValue);
|
if (scopeValue) form.append('scopeValue', scopeValue);
|
||||||
if (newNotes.trim()) form.append('notes', newNotes.trim());
|
if (newNotes.trim()) form.append('notes', newNotes.trim());
|
||||||
@@ -94,9 +98,10 @@
|
|||||||
const body = (await res.json()) as { ok: boolean; error?: string; script?: { id: number; name: string } };
|
const body = (await res.json()) as { ok: boolean; error?: string; script?: { id: number; name: string } };
|
||||||
if (!res.ok || !body.ok) { flash = body.error ?? `HTTP ${res.status}`; return; }
|
if (!res.ok || !body.ok) { flash = body.error ?? `HTTP ${res.status}`; return; }
|
||||||
successBanner = body.script
|
successBanner = body.script
|
||||||
? { name: body.script.name, artifactId: body.script.id, version: 1 }
|
? { name: body.script.name, artifactId: body.script.id, version: 1, release_version: newReleaseVersion.trim() || null }
|
||||||
: null;
|
: null;
|
||||||
newName = ''; newScopeType = 'none'; newScopeGame = ''; newScopeMap = ''; newNotes = ''; newFile = null;
|
newName = ''; newScopeType = 'none'; newScopeGame = ''; newScopeMap = ''; newNotes = ''; newFile = null;
|
||||||
|
newReleaseVersion = '';
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
@@ -121,7 +126,7 @@
|
|||||||
if (!a.latestVersion) return '';
|
if (!a.latestVersion) return '';
|
||||||
if (a.current_version_id === null) return 'untested';
|
if (a.current_version_id === null) return 'untested';
|
||||||
if (a.latestVersion.id !== a.current_version_id) {
|
if (a.latestVersion.id !== a.current_version_id) {
|
||||||
return `v${a.latestVersion.version} ${a.latestVersion.state.replace('-', ' ')}`;
|
return `${artifactVersionLabel(a.latestVersion)} ${a.latestVersion.state.replace('-', ' ')}`;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -148,7 +153,7 @@
|
|||||||
<h2 class="panel-title">Upload a plugin</h2>
|
<h2 class="panel-title">Upload a plugin</h2>
|
||||||
{#if successBanner}
|
{#if successBanner}
|
||||||
<div class="banner">
|
<div class="banner">
|
||||||
<strong>v{successBanner.version} of "{successBanner.name}" uploaded — untested.</strong>
|
<strong>{artifactVersionLabel(successBanner)} of "{successBanner.name}" uploaded — untested.</strong>
|
||||||
<span>Open a beta on <a href="/betas">the betas page</a> to test it before promotion.</span>
|
<span>Open a beta on <a href="/betas">the betas page</a> to test it before promotion.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -156,6 +161,9 @@
|
|||||||
<Field label="Name">
|
<Field label="Name">
|
||||||
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
|
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Release version" hint="Optional. Use the publisher’s version or release tag, such as v3.1.0.">
|
||||||
|
<Input bind:value={newReleaseVersion} placeholder="v3.1.0" disabled={uploading} />
|
||||||
|
</Field>
|
||||||
<Field label="Notes" hint="Optional. What changed in this version.">
|
<Field label="Notes" hint="Optional. What changed in this version.">
|
||||||
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
|
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -218,7 +226,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row-meta">
|
<div class="row-meta">
|
||||||
<span class="path">{a.target_rel_path}</span>
|
<span class="path">{a.target_rel_path}</span>
|
||||||
<span>v{a.latestVersion?.version ?? '—'} · {a.versionCount} version{a.versionCount === 1 ? '' : 's'}</span>
|
<span>{artifactVersionLabel(a.latestVersion)} · {a.versionCount} version{a.versionCount === 1 ? '' : 's'}</span>
|
||||||
{#if a.servers.length}
|
{#if a.servers.length}
|
||||||
<span>{a.servers.length} matching server{a.servers.length === 1 ? '' : 's'}</span>
|
<span>{a.servers.length} matching server{a.servers.length === 1 ? '' : 's'}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ describe('plugin kind writes to plugin destination', () => {
|
|||||||
const { script } = createScript({
|
const { script } = createScript({
|
||||||
name: 'demo plugin',
|
name: 'demo plugin',
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
|
releaseVersion: 'v3.1.0',
|
||||||
targetRelPath: 'demo.dll',
|
targetRelPath: 'demo.dll',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
scopeValue: 't5',
|
scopeValue: 't5',
|
||||||
@@ -149,5 +150,8 @@ describe('plugin kind writes to plugin destination', () => {
|
|||||||
const bytes = await readFile(pluginPath);
|
const bytes = await readFile(pluginPath);
|
||||||
expect(bytes.equals(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(true);
|
expect(bytes.equals(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(true);
|
||||||
expect(betaId).toBeGreaterThan(0);
|
expect(betaId).toBeGreaterThan(0);
|
||||||
|
const { GET } = await import('../../src/routes/api/betas/[id]/+server.js');
|
||||||
|
const detail = await (await GET({ params: { id: String(betaId) } })).json();
|
||||||
|
expect(detail.scripts[0].version.release_version).toBe('v3.1.0');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture();
|
||||||
|
delete process.env.KOMODO_ADDRESS;
|
||||||
|
delete process.env.RCON_PASSWORD;
|
||||||
|
});
|
||||||
|
afterEach(async () => { await fixture.cleanup(); });
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
name: 't6-gsc-utils', kind: 'plugin' as const,
|
||||||
|
targetRelPath: 't6-gsc-utils.dll', content: Buffer.from([0, 1, 2, 3])
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('plugin release versions', () => {
|
||||||
|
it('keeps release tags independent of upload order and preserves binary identity', async () => {
|
||||||
|
const { createScript, uploadNewVersion, listVersions, getVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { script, version } = createScript({ ...plugin, releaseVersion: ' v3.1.0 ' });
|
||||||
|
expect(version).toMatchObject({ version: 1, release_version: 'v3.1.0' });
|
||||||
|
const older = uploadNewVersion(script.id, Buffer.from([4, 5]), 'Known good release', 'v3.0.4');
|
||||||
|
const rebuild = uploadNewVersion(script.id, Buffer.from([6, 7]), 'Rebuilt', 'v3.0.4');
|
||||||
|
expect(listVersions(script.id).map(v => [v.version, v.release_version])).toEqual([
|
||||||
|
[3, 'v3.0.4'], [2, 'v3.0.4'], [1, 'v3.1.0']
|
||||||
|
]);
|
||||||
|
expect(older.sha256).not.toBe(rebuild.sha256);
|
||||||
|
expect(getVersion(version.id)!.content).toEqual(plugin.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows unlabeled uploads and non-semver release tags', async () => {
|
||||||
|
const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { script, version } = createScript(plugin);
|
||||||
|
expect(version.release_version).toBeNull();
|
||||||
|
expect(uploadNewVersion(script.id, plugin.content, undefined, ' ').release_version).toBeNull();
|
||||||
|
expect(uploadNewVersion(script.id, plugin.content, undefined, '2026.09-rc1').release_version).toBe('2026.09-rc1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid labels before storing an artifact or new upload', async () => {
|
||||||
|
const { createScript, uploadNewVersion, listScripts, listVersions } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
expect(() => createScript({ ...plugin, releaseVersion: 'x'.repeat(81) })).toThrow(/release version/);
|
||||||
|
expect(listScripts()).toHaveLength(0);
|
||||||
|
const { script } = createScript(plugin);
|
||||||
|
expect(() => uploadNewVersion(script.id, plugin.content, undefined, 'v3\n.1')).toThrow(/release version/);
|
||||||
|
expect(listVersions(script.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates an existing database without changing its stored versions', async () => {
|
||||||
|
const { createScript, getVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { version } = createScript(plugin);
|
||||||
|
db().exec('ALTER TABLE script_versions DROP COLUMN release_version');
|
||||||
|
closeDb();
|
||||||
|
expect(getVersion(version.id)).toMatchObject({
|
||||||
|
id: version.id, version: 1, release_version: null, sha256: version.sha256, content: plugin.content
|
||||||
|
});
|
||||||
|
closeDb();
|
||||||
|
expect(getVersion(version.id)!.release_version).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round trips release versions through plugin creation, version upload, list and detail APIs', async () => {
|
||||||
|
const plugins = await import('../../src/routes/api/plugins/+server.js');
|
||||||
|
const detail = await import('../../src/routes/api/scripts/[id]/+server.js');
|
||||||
|
const uploads = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
|
||||||
|
const url = new URL('http://localhost/api/plugins');
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('name', plugin.name);
|
||||||
|
form.set('releaseVersion', 'v3.1.0');
|
||||||
|
form.set('content', new File([plugin.content], plugin.targetRelPath));
|
||||||
|
const created = await plugins.POST({ url, request: new Request(url, { method: 'POST', body: form }) });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const { script } = await created.json();
|
||||||
|
const listed = await (await plugins.GET({ url })).json();
|
||||||
|
expect(listed.scripts[0].latestVersion.release_version).toBe('v3.1.0');
|
||||||
|
const next = new FormData();
|
||||||
|
next.set('releaseVersion', 'v3.1.1');
|
||||||
|
next.set('content', new File([new Uint8Array([4, 5])], plugin.targetRelPath));
|
||||||
|
const uploaded = await uploads.POST({ params: { id: String(script.id) }, request: new Request(url, { method: 'POST', body: next }) });
|
||||||
|
expect(uploaded.status).toBe(200);
|
||||||
|
expect((await uploaded.json()).version).toMatchObject({ version: 2, release_version: 'v3.1.1' });
|
||||||
|
const result = await (await detail.GET({ params: { id: String(script.id) } })).json();
|
||||||
|
expect(result.versions.map((v: { release_version: string }) => v.release_version)).toEqual(['v3.1.1', 'v3.1.0']);
|
||||||
|
expect(result.versions[0]).not.toHaveProperty('content');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user