This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
/** 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
|
||||
version: { version: number; release_version?: string | null } | null | undefined,
|
||||
kind?: string
|
||||
): string {
|
||||
if (!version) return '—';
|
||||
return version.release_version
|
||||
? `${version.release_version} (upload ${version.version})`
|
||||
: `v${version.version}`;
|
||||
: kind === 'script'
|
||||
? `Unversioned (upload ${version.version})`
|
||||
: `v${version.version}`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { gameTitle } from './ui/server-labels.js';
|
||||
export interface LibraryVersion {
|
||||
id: number;
|
||||
version: number;
|
||||
release_version?: string | null;
|
||||
sha256: string;
|
||||
size: number;
|
||||
state: 'untested' | 'tested' | 'test-failed';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/** TWS source releases use three numeric components, without tags or suffixes. */
|
||||
export function scriptVersionError(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value.trim()) return 'A script release version is required.';
|
||||
if (value.length > 80 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value.trim()))
|
||||
return 'Use MAJOR.MINOR.PATCH for the script release version, such as 1.2.0.';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Ignore non-script components (for example native plugins) in a TWS manifest. */
|
||||
export function parseScriptVersions(text: string): Record<string, string> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('Choose a valid versions.json file.');
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data))
|
||||
throw new Error('versions.json must map script filenames to release versions.');
|
||||
const entries = Object.entries(data).filter(([name]) => name.endsWith('.gsc'));
|
||||
if (!entries.length) throw new Error('versions.json contains no .gsc filenames.');
|
||||
for (const [name, value] of entries) {
|
||||
if (name.includes('/') || name.includes('\\'))
|
||||
throw new Error(`Use an exact filename in versions.json: ${name}`);
|
||||
const error = scriptVersionError(value);
|
||||
if (error) throw new Error(`${name}: ${error}`);
|
||||
}
|
||||
return Object.fromEntries(entries.map(([name, value]) => [name, (value as string).trim()]));
|
||||
}
|
||||
|
||||
/** Uploaded filenames must never resolve inherited object properties. */
|
||||
export function manifestVersion(versions: Record<string, string>, filename: string): string {
|
||||
return Object.hasOwn(versions, filename) ? versions[filename] : '';
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { scriptVersionError } from '../../script-version.js';
|
||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
@@ -73,6 +74,7 @@ export class ScriptError extends Error {
|
||||
| 'baseline-exists'
|
||||
| 'empty-overlay'
|
||||
| 'invalid-release-version'
|
||||
| 'release-version-conflict'
|
||||
| 'too-large'
|
||||
) {
|
||||
super(message);
|
||||
@@ -95,6 +97,14 @@ function normalizeReleaseVersion(value: string | null | undefined): string | nul
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
function releaseForKind(kind: ArtifactKind, value: string | null | undefined): string | null {
|
||||
if (kind === 'script') {
|
||||
const error = scriptVersionError(value);
|
||||
if (error) throw new ScriptError(error, 'invalid-release-version');
|
||||
}
|
||||
return normalizeReleaseVersion(value);
|
||||
}
|
||||
|
||||
export function getScript(id: number): ScriptRow | undefined {
|
||||
return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as ScriptRow | undefined;
|
||||
}
|
||||
@@ -273,7 +283,7 @@ export function createScript(input: CreateScriptInput): {
|
||||
throw new ScriptError(`script "${input.name}" already exists`, 'name-taken');
|
||||
|
||||
const sha = sha256(input.content);
|
||||
const releaseVersion = normalizeReleaseVersion(input.releaseVersion);
|
||||
const releaseVersion = releaseForKind(kind, input.releaseVersion);
|
||||
const size = input.content.byteLength;
|
||||
|
||||
const insertScript = db().prepare(
|
||||
@@ -335,27 +345,39 @@ export function uploadNewVersion(
|
||||
}
|
||||
}
|
||||
|
||||
const latest = getLatestVersion(scriptId);
|
||||
const nextVersion = (latest?.version ?? 0) + 1;
|
||||
const release = releaseForKind(script.kind, releaseVersion);
|
||||
const sha = sha256(content);
|
||||
const release = normalizeReleaseVersion(releaseVersion);
|
||||
return db().transaction(() => {
|
||||
if (script.kind === 'script') {
|
||||
const existing = listVersions(scriptId).filter((v) => v.release_version === release);
|
||||
if (existing.some((v) => v.sha256 !== sha)) {
|
||||
throw new ScriptError(
|
||||
`Release ${release} already has different contents. Bump the script release version.`,
|
||||
'release-version-conflict'
|
||||
);
|
||||
}
|
||||
if (existing.length) return existing[0];
|
||||
}
|
||||
const latest = getLatestVersion(scriptId);
|
||||
const nextVersion = (latest?.version ?? 0) + 1;
|
||||
|
||||
const res = db()
|
||||
.prepare(
|
||||
`INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
|
||||
const res = db()
|
||||
.prepare(
|
||||
`INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
scriptId,
|
||||
nextVersion,
|
||||
sha,
|
||||
content.byteLength,
|
||||
putFileBlob(content),
|
||||
notes ?? null,
|
||||
script.kind,
|
||||
release
|
||||
);
|
||||
return getVersion(Number(res.lastInsertRowid))!;
|
||||
)
|
||||
.run(
|
||||
scriptId,
|
||||
nextVersion,
|
||||
sha,
|
||||
content.byteLength,
|
||||
putFileBlob(content),
|
||||
notes ?? null,
|
||||
script.kind,
|
||||
release
|
||||
);
|
||||
return getVersion(Number(res.lastInsertRowid))!;
|
||||
})();
|
||||
}
|
||||
|
||||
function validateScope(scopeType: ScopeType | null, scopeValue: string | null): void {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
|
||||
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -69,7 +71,13 @@
|
||||
let versions = $state<Version[]>([]);
|
||||
let matchedServers = $state<string[]>([]);
|
||||
let installedVersions = $state<
|
||||
Array<{ server: string; version: number; versionId: number; recordedAt: string }>
|
||||
Array<{
|
||||
server: string;
|
||||
version: number;
|
||||
versionId: number;
|
||||
release_version: string | null;
|
||||
recordedAt: string;
|
||||
}>
|
||||
>([]);
|
||||
interface ScopeImpact {
|
||||
before: string[];
|
||||
@@ -103,6 +111,7 @@
|
||||
let versionFile = $state<File | null>(null);
|
||||
let versionNotes = $state('');
|
||||
let releaseVersion = $state('');
|
||||
let versionManifest = $state<Record<string, string>>({});
|
||||
let uploadingVersion = $state(false);
|
||||
|
||||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||||
@@ -244,6 +253,7 @@
|
||||
function openUploadModal(): void {
|
||||
if (!editable || runningBetaId !== null) return;
|
||||
releaseVersion = '';
|
||||
versionManifest = {};
|
||||
versionFile = null;
|
||||
versionNotes = '';
|
||||
flash = null;
|
||||
@@ -271,16 +281,23 @@
|
||||
function pickVersionFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
versionFile = t.files?.[0] ?? null;
|
||||
if (!releaseVersion.trim() && versionFile)
|
||||
releaseVersion = manifestVersion(versionManifest, versionFile.name);
|
||||
}
|
||||
|
||||
async function uploadVersion(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!editable || !versionFile || uploadingVersion) return;
|
||||
if (family === 'script' && scriptVersionError(releaseVersion)) {
|
||||
flash = scriptVersionError(releaseVersion);
|
||||
return;
|
||||
}
|
||||
uploadingVersion = true;
|
||||
flash = null;
|
||||
const form = new FormData();
|
||||
form.append('content', versionFile);
|
||||
if (family === 'plugin') form.append('releaseVersion', releaseVersion.trim());
|
||||
if (family === 'plugin' || family === 'script')
|
||||
form.append('releaseVersion', releaseVersion.trim());
|
||||
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
|
||||
try {
|
||||
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||||
@@ -419,7 +436,7 @@
|
||||
{#if latestVersion}<Card padding="lg"
|
||||
><div class="release-summary">
|
||||
<div>
|
||||
<h2>Latest {artifactVersionLabel(latestVersion)}</h2>
|
||||
<h2>Latest {artifactVersionLabel(latestVersion, family)}</h2>
|
||||
<StatusBadge
|
||||
state={latestVersion.id === a.current_version_id
|
||||
? 'promoted'
|
||||
@@ -517,7 +534,7 @@
|
||||
<span class="k">Promoted version</span>
|
||||
<span class="v mono">
|
||||
{#if currentVersion}
|
||||
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(
|
||||
{artifactVersionLabel(currentVersion, family)} · {currentVersion.sha256.slice(
|
||||
0,
|
||||
8
|
||||
)}
|
||||
@@ -541,7 +558,9 @@
|
||||
<a href={'/servers/' + encodeURIComponent(installed.server)}
|
||||
>{serverTitle(installed.server)}</a
|
||||
>
|
||||
· v{installed.version} · <RelativeTime value={installed.recordedAt} />
|
||||
· {artifactVersionLabel(installed, family)} · <RelativeTime
|
||||
value={installed.recordedAt}
|
||||
/>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</details>{:else if family !== 'config'}<p class="dim">
|
||||
@@ -575,7 +594,7 @@
|
||||
<div class="version-list">
|
||||
{#each versions.slice(0, limit) as v (v.id)}
|
||||
<div class="version-row" class:current={a.current_version_id === v.id}>
|
||||
<span class="v-num">{artifactVersionLabel(v)}</span>
|
||||
<span class="v-num">{artifactVersionLabel(v, family)}</span>
|
||||
<span class="v-notes"
|
||||
>{v.notes ?? ''}
|
||||
{#if v.source}<a
|
||||
@@ -750,14 +769,28 @@
|
||||
disabled={uploadingVersion}
|
||||
/>
|
||||
</Field>
|
||||
{#if family === 'plugin'}
|
||||
{#if family === 'script'}
|
||||
<ScriptVersionManifest
|
||||
disabled={uploadingVersion}
|
||||
onload={(versions) => {
|
||||
versionManifest = versions;
|
||||
if (versionFile && !releaseVersion.trim())
|
||||
releaseVersion = manifestVersion(versions, versionFile.name);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if family === 'plugin' || family === 'script'}
|
||||
<Field
|
||||
label="Release version"
|
||||
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
|
||||
hint={family === 'script'
|
||||
? 'Required. Use MAJOR.MINOR.PATCH, such as 1.2.0.'
|
||||
: 'Optional. Use the publisher’s version or release tag, such as v3.1.0.'}
|
||||
>
|
||||
<Input
|
||||
bind:value={releaseVersion}
|
||||
placeholder="v3.1.0"
|
||||
placeholder={family === 'script' ? '1.2.0' : 'v3.1.0'}
|
||||
required={family === 'script'}
|
||||
maxlength={80}
|
||||
disabled={uploadingVersion}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
|
||||
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
|
||||
import Field from './primitives/Field.svelte';
|
||||
import Input from './primitives/Input.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import DiffViewer from './DiffViewer.svelte';
|
||||
@@ -31,19 +35,38 @@
|
||||
} = $props();
|
||||
interface Replacement {
|
||||
file: File;
|
||||
releaseVersion: string;
|
||||
sha: string;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
}
|
||||
let manifest = $state<Record<string, string>>({});
|
||||
let replacements = $state<Record<number, Replacement>>({});
|
||||
let notes = $state(''),
|
||||
error = $state(''),
|
||||
reviewing = $state(false),
|
||||
busy = $state(false);
|
||||
const versionsValid = $derived(
|
||||
files.every(
|
||||
(entry) =>
|
||||
!replacements[entry.id] ||
|
||||
entry.kind !== 'script' ||
|
||||
!scriptVersionError(replacements[entry.id].releaseVersion)
|
||||
)
|
||||
);
|
||||
function applyManifest(versions: Record<string, string>) {
|
||||
manifest = versions;
|
||||
for (const entry of files) {
|
||||
const replacement = replacements[entry.id];
|
||||
if (entry.kind === 'script' && replacement && !replacement.releaseVersion.trim())
|
||||
replacement.releaseVersion = manifestVersion(manifest, replacement.file.name);
|
||||
}
|
||||
}
|
||||
const count = $derived(Object.keys(replacements).length);
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
replacements = {};
|
||||
manifest = {};
|
||||
notes = '';
|
||||
error = '';
|
||||
reviewing = false;
|
||||
@@ -90,7 +113,13 @@
|
||||
after = new TextDecoder().decode(bytes);
|
||||
}
|
||||
}
|
||||
replacements[entry.id] = { file, sha, before, after };
|
||||
replacements[entry.id] = {
|
||||
file,
|
||||
sha,
|
||||
before,
|
||||
after,
|
||||
releaseVersion: entry.kind === 'script' ? manifestVersion(manifest, file.name) : ''
|
||||
};
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
@@ -98,14 +127,17 @@
|
||||
}
|
||||
}
|
||||
async function deploy() {
|
||||
if (!versionsValid) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set('expectedRevision', String(revision));
|
||||
form.set('notes', notes);
|
||||
for (const [scriptId, replacement] of Object.entries(replacements))
|
||||
for (const [scriptId, replacement] of Object.entries(replacements)) {
|
||||
form.set(`file:${scriptId}`, replacement.file);
|
||||
form.set(`release:${scriptId}`, replacement.releaseVersion.trim());
|
||||
}
|
||||
const response = await fetch(`/api/betas/${betaId}/revisions`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
@@ -132,12 +164,15 @@
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if !reviewing}
|
||||
<p>Choose replacements for the files you changed.</p>
|
||||
{#if files.some((entry) => entry.kind === 'script')}
|
||||
<ScriptVersionManifest disabled={busy} onload={applyManifest} />
|
||||
{/if}
|
||||
<div class="files">
|
||||
{#each files as entry}
|
||||
<label
|
||||
><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code
|
||||
>{entry.target_rel_path}</code
|
||||
>
|
||||
><strong
|
||||
>{entry.name} · {artifactVersionLabel(entry.version, entry.kind)}</strong
|
||||
><code>{entry.target_rel_path}</code>
|
||||
<input
|
||||
type="file"
|
||||
disabled={busy}
|
||||
@@ -145,6 +180,26 @@
|
||||
/>
|
||||
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
|
||||
</label>
|
||||
{#if replacements[entry.id] && (entry.kind === 'script' || entry.kind === 'plugin')}
|
||||
<Field
|
||||
label={`${entry.name} release version`}
|
||||
hint={entry.kind === 'script'
|
||||
? 'Required. Use MAJOR.MINOR.PATCH, such as 1.2.0.'
|
||||
: 'Optional publisher release tag.'}
|
||||
>
|
||||
<Input
|
||||
bind:value={replacements[entry.id].releaseVersion}
|
||||
required={entry.kind === 'script'}
|
||||
maxlength={80}
|
||||
disabled={busy}
|
||||
/>
|
||||
</Field>
|
||||
{#if entry.kind === 'script' && scriptVersionError(replacements[entry.id].releaseVersion)}
|
||||
<p class="ui-error">
|
||||
{scriptVersionError(replacements[entry.id].releaseVersion)}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<label class="notes"
|
||||
@@ -177,6 +232,9 @@
|
||||
{@const replacement = replacements[entry.id]}
|
||||
<section>
|
||||
<h3>{entry.name}</h3>
|
||||
{#if replacement.releaseVersion}<p>
|
||||
Release {entry.version?.release_version ?? 'Unversioned'} → {replacement.releaseVersion.trim()}
|
||||
</p>{/if}
|
||||
<p>
|
||||
<code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()}
|
||||
bytes)
|
||||
@@ -204,7 +262,7 @@
|
||||
{:else}<Button
|
||||
variant="primary"
|
||||
onclick={() => (reviewing = true)}
|
||||
disabled={busy || !count}>Review update</Button
|
||||
disabled={busy || !count || !versionsValid}>Review update</Button
|
||||
>{/if}
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
@@ -10,15 +10,21 @@
|
||||
let {
|
||||
current,
|
||||
latest,
|
||||
kind,
|
||||
betaId = null
|
||||
}: { current: Version | null; latest: Version | null; betaId?: number | null } = $props();
|
||||
}: {
|
||||
current: Version | null;
|
||||
latest: Version | null;
|
||||
betaId?: number | null;
|
||||
kind?: string;
|
||||
} = $props();
|
||||
const newer = $derived(latest && latest.id !== current?.id ? latest : null);
|
||||
const ambiguous = $derived(
|
||||
!!newer?.release_version && newer.release_version === current?.release_version
|
||||
);
|
||||
function label(version: Version) {
|
||||
return ambiguous
|
||||
? artifactVersionLabel(version)
|
||||
return kind === 'script' || ambiguous
|
||||
? artifactVersionLabel(version, kind)
|
||||
: version.release_version || `v${version.version}`;
|
||||
}
|
||||
const state = $derived(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
@@ -20,23 +23,34 @@
|
||||
scriptId: number;
|
||||
versionId: number;
|
||||
version: number;
|
||||
release_version: string | null;
|
||||
}
|
||||
interface Upload {
|
||||
key: number;
|
||||
file: File;
|
||||
target: number | 'new' | '';
|
||||
name: string;
|
||||
releaseVersion: string;
|
||||
duplicates: Duplicate[];
|
||||
inspecting: boolean;
|
||||
inspected: boolean;
|
||||
error: string;
|
||||
result: (BetaSelection & { version: number; reused: boolean }) | null;
|
||||
result:
|
||||
(BetaSelection & { version: number; release_version: string; reused: boolean }) | null;
|
||||
}
|
||||
let dialog: HTMLDialogElement;
|
||||
let input: HTMLInputElement;
|
||||
let queue = $state<Upload[]>([]);
|
||||
let busy = $state(false);
|
||||
let notes = $state('');
|
||||
let manifest = $state<Record<string, string>>({});
|
||||
function applyManifest(versions: Record<string, string>) {
|
||||
manifest = versions;
|
||||
for (const item of queue) {
|
||||
if (!item.result && !item.releaseVersion.trim())
|
||||
item.releaseVersion = manifestVersion(manifest, item.file.name);
|
||||
}
|
||||
}
|
||||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let scopeGame = $state('');
|
||||
let scopeMap = $state('');
|
||||
@@ -80,6 +94,7 @@
|
||||
// only when there is no pending file to lose.
|
||||
if (!pending.length) {
|
||||
queue = [];
|
||||
manifest = {};
|
||||
notes = '';
|
||||
message = '';
|
||||
preferredTarget = scriptId ?? null;
|
||||
@@ -121,6 +136,7 @@
|
||||
preferredTarget ??
|
||||
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
||||
name: file.name.replace(/\.[^.]+$/, ''),
|
||||
releaseVersion: manifestVersion(manifest, file.name),
|
||||
duplicates: [],
|
||||
inspecting: true,
|
||||
inspected: false,
|
||||
@@ -153,9 +169,13 @@
|
||||
}
|
||||
}
|
||||
function duplicate(item: Upload) {
|
||||
return item.duplicates.find((d) => d.scriptId === item.target);
|
||||
return item.duplicates.find(
|
||||
(d) => d.scriptId === item.target && d.release_version === item.releaseVersion.trim()
|
||||
);
|
||||
}
|
||||
function validation(item: Upload): string {
|
||||
const versionError = scriptVersionError(item.releaseVersion);
|
||||
if (versionError) return versionError;
|
||||
if (item.target === '')
|
||||
return 'Several scripts use this filename. Choose which script to update.';
|
||||
if (item.target === 'new') {
|
||||
@@ -194,6 +214,7 @@
|
||||
const form = new FormData();
|
||||
form.append('content', item.file);
|
||||
form.append('notes', notes.trim());
|
||||
form.append('releaseVersion', item.releaseVersion.trim());
|
||||
const creating = item.target === 'new';
|
||||
if (creating) {
|
||||
form.append('name', item.name.trim());
|
||||
@@ -211,6 +232,7 @@
|
||||
scriptId: creating ? body.script.id : (item.target as number),
|
||||
versionId: creating ? body.versionId : body.version.id,
|
||||
version: creating ? 1 : body.version.version,
|
||||
release_version: item.releaseVersion.trim(),
|
||||
reused: !!body.reused
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -286,6 +308,7 @@
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ScriptVersionManifest disabled={busy || inspecting} onload={applyManifest} />
|
||||
<div class="queue" aria-live="polite">
|
||||
{#each queue as item (item.key)}
|
||||
<section class="upload-item" aria-label={item.file.name}>
|
||||
@@ -304,11 +327,26 @@
|
||||
</div>
|
||||
{#if item.result}
|
||||
<p class="success">
|
||||
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result
|
||||
.version} ·
|
||||
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as {artifactVersionLabel(
|
||||
item.result,
|
||||
'script'
|
||||
)} ·
|
||||
<a href="/scripts/{item.result.scriptId}">View script →</a>
|
||||
</p>
|
||||
{:else}
|
||||
<Field
|
||||
label="Release version"
|
||||
hint="Required. Use MAJOR.MINOR.PATCH, such as 1.2.0."
|
||||
>
|
||||
<Input
|
||||
bind:value={item.releaseVersion}
|
||||
required
|
||||
maxlength={80}
|
||||
placeholder="1.2.0"
|
||||
disabled={busy}
|
||||
oninput={() => (item.error = '')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Destination">
|
||||
<Select
|
||||
bind:value={item.target}
|
||||
@@ -336,8 +374,10 @@
|
||||
</p>{/if}
|
||||
{#if item.inspecting}<p class="note">Checking file contents…</p>
|
||||
{:else if duplicate(item)}<p class="success">
|
||||
Already uploaded as v{duplicate(item)!.version}. We’ll reuse that
|
||||
version.
|
||||
Already uploaded as {artifactVersionLabel(
|
||||
duplicate(item),
|
||||
'script'
|
||||
)}. We’ll reuse that version.
|
||||
</p>
|
||||
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
|
||||
New version for {scripts.find((s) => s.id === item.target)?.name}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { parseScriptVersions } from '$lib/script-version.js';
|
||||
import Field from './primitives/Field.svelte';
|
||||
import Input from './primitives/Input.svelte';
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
onload
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onload: (versions: Record<string, string>) => void;
|
||||
} = $props();
|
||||
let message = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
async function choose(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
loading = true;
|
||||
message = error = '';
|
||||
try {
|
||||
if (file.size > 1024 * 1024)
|
||||
throw new Error('versions.json must be smaller than 1 MiB.');
|
||||
const versions = parseScriptVersions(await file.text());
|
||||
onload(versions);
|
||||
message = `Loaded ${Object.keys(versions).length} script versions. Review the matched values below.`;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field
|
||||
label="Version manifest (optional)"
|
||||
hint="Choose versions.json from the same source checkout as your files. Matches exact filenames; fills empty release version fields."
|
||||
>
|
||||
<Input type="file" accept=".json" disabled={disabled || loading} onchange={choose} />
|
||||
</Field>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if message}<p role="status">{message}</p>{/if}
|
||||
@@ -39,7 +39,14 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
if (isHttpError(error)) throw error;
|
||||
return json(
|
||||
{ ok: false, error: (error as Error).message },
|
||||
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 }
|
||||
{
|
||||
status:
|
||||
error instanceof ScriptError && error.kind === 'invalid-release-version'
|
||||
? 400
|
||||
: error instanceof BetaError || error instanceof ScriptError
|
||||
? 409
|
||||
: 400
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,10 @@ export async function POST({ request, url }: { request: Request; url: URL }) {
|
||||
return json({ ok: true, script, versionId: version.id });
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
return json(
|
||||
{ ok: false, error: err.message, kind: err.kind },
|
||||
{ status: err.kind === 'invalid-release-version' ? 400 : 409 }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
script.kind === 'script' || script.kind === 'plugin'
|
||||
? db()
|
||||
.prepare(
|
||||
`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`
|
||||
`SELECT i.server, v.version, v.release_version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`
|
||||
)
|
||||
.all(id, script.target_rel_path)
|
||||
: [],
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { artifactFormBody } from '$lib/server/request-body.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
uploadNewVersion,
|
||||
getScript,
|
||||
getLatestVersion,
|
||||
ScriptError
|
||||
} from '$lib/server/fleet/scripts.js';
|
||||
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(idRaw: string): number | null {
|
||||
@@ -11,7 +14,7 @@ function parseId(idRaw: string): number | null {
|
||||
}
|
||||
|
||||
// Multipart POST — creates a new version from an uploaded file. Fields:
|
||||
// content (file), notes and releaseVersion (optional). Response includes a "betaRequired" hint
|
||||
// content (file), notes (optional) and releaseVersion (required for scripts). Response includes a "betaRequired" hint
|
||||
// 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 }) {
|
||||
const scriptId = parseId(params.id);
|
||||
@@ -45,21 +48,16 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
const sha = createHash('sha256').update(content).digest('hex');
|
||||
const existing = db()
|
||||
.prepare(
|
||||
'SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1'
|
||||
)
|
||||
.get(scriptId, sha);
|
||||
if (existing) return json({ ok: true, version: existing, reused: true });
|
||||
}
|
||||
try {
|
||||
const releaseVersion = form.has('releaseVersion')
|
||||
? String(form.get('releaseVersion'))
|
||||
: undefined;
|
||||
const previous = getLatestVersion(scriptId);
|
||||
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
|
||||
return json({
|
||||
ok: true,
|
||||
reused: script.kind === 'script' && version.version <= (previous?.version ?? 0),
|
||||
version: {
|
||||
id: version.id,
|
||||
version: version.version,
|
||||
@@ -73,7 +71,10 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
return json(
|
||||
{ ok: false, error: err.message, kind: err.kind },
|
||||
{ status: err.kind === 'invalid-release-version' ? 400 : 409 }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
.digest('hex');
|
||||
const duplicates = db()
|
||||
.prepare(
|
||||
`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
|
||||
`SELECT v.script_id AS scriptId, v.id AS versionId, v.version, v.release_version
|
||||
FROM script_versions v JOIN scripts s ON s.id = v.script_id
|
||||
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`
|
||||
)
|
||||
|
||||
@@ -261,7 +261,11 @@
|
||||
>
|
||||
<td class="files"
|
||||
>{#if beta.scripts.length === 1}{beta.scripts[0].name}
|
||||
<span>{artifactVersionLabel(beta.scripts[0].version)}</span
|
||||
<span
|
||||
>{artifactVersionLabel(
|
||||
beta.scripts[0].version,
|
||||
beta.scripts[0].kind
|
||||
)}</span
|
||||
>{:else}{beta.scripts.length} files{/if}</td
|
||||
>
|
||||
<td
|
||||
|
||||
@@ -620,7 +620,7 @@
|
||||
$page.url
|
||||
)}>{file.name}</a
|
||||
>
|
||||
· {artifactVersionLabel(file.version)}</summary
|
||||
· {artifactVersionLabel(file.version, file.kind)}</summary
|
||||
>
|
||||
<p><code>{file.target_rel_path}</code></p>
|
||||
{#if file.version}<p class="muted">
|
||||
@@ -661,7 +661,10 @@
|
||||
<a
|
||||
href={`/api/betas/${id}/revisions/${item.revision}/files/${file.script_id}`}
|
||||
download={file.path.split('/').pop()}
|
||||
>{file.name} · {artifactVersionLabel(file)}</a
|
||||
>{file.name} · {artifactVersionLabel(
|
||||
file,
|
||||
file.kind
|
||||
)}</a
|
||||
><code>{file.path}</code><span
|
||||
>{file.sha256.slice(0, 12)}</span
|
||||
>
|
||||
@@ -744,7 +747,8 @@
|
||||
<div class="file-list">
|
||||
{#each preview.files as file}<details open>
|
||||
<summary
|
||||
>{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers</summary
|
||||
>{file.name} · {artifactVersionLabel(file, file.kind)} · {file.servers
|
||||
.length} servers</summary
|
||||
>
|
||||
<p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0, 12)}</p>
|
||||
<ul>
|
||||
|
||||
@@ -712,10 +712,14 @@
|
||||
? 'Loading versions…'
|
||||
: 'Choose a version'}</option
|
||||
>{#each versionsFor(pick) as version}<option value={version.id}
|
||||
>{artifactVersionLabel(version)} · {version.sha256.slice(
|
||||
0,
|
||||
8
|
||||
)} · {version.state.replaceAll('-', ' ')}</option
|
||||
>{artifactVersionLabel(
|
||||
version,
|
||||
scripts.find((file) => file.id === pick.scriptId)
|
||||
?.kind
|
||||
)} · {version.sha256.slice(0, 8)} · {version.state.replaceAll(
|
||||
'-',
|
||||
' '
|
||||
)}</option
|
||||
>{/each}</Select
|
||||
></Field
|
||||
>
|
||||
@@ -926,7 +930,8 @@
|
||||
?.name}</strong
|
||||
>
|
||||
· {artifactVersionLabel(
|
||||
versionsFor(pick).find((v) => v.id === pick.versionId)
|
||||
versionsFor(pick).find((v) => v.id === pick.versionId),
|
||||
scripts.find((file) => file.id === pick.scriptId)?.kind
|
||||
)} · {versionsFor(pick)
|
||||
.find((v) => v.id === pick.versionId)
|
||||
?.sha256.slice(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
@@ -336,6 +337,7 @@
|
||||
<td>{scopeLabel(s)}</td>
|
||||
<td
|
||||
><LibraryVersions
|
||||
kind="script"
|
||||
current={s.currentVersion}
|
||||
latest={s.latestVersion}
|
||||
betaId={s.runningBetaId}
|
||||
@@ -389,7 +391,10 @@
|
||||
>
|
||||
{#if reviewSelection}<ul class="selection-review">
|
||||
{#each selectedScripts as script}<li>
|
||||
{script.name} · v{script.latestVersion?.version}<Button
|
||||
{script.name} · {artifactVersionLabel(
|
||||
script.latestVersion,
|
||||
'script'
|
||||
)}<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => toggle(script.id)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
||||
@@ -564,8 +565,15 @@
|
||||
)}>{f.relPath}</a
|
||||
>{:else}{f.relPath}{/if}</td
|
||||
><td>{f.kind ?? 'file'}</td><td
|
||||
>{f.releaseVersion ??
|
||||
(f.version ? `v${f.version}` : '—')}</td
|
||||
>{artifactVersionLabel(
|
||||
f.version
|
||||
? {
|
||||
version: f.version,
|
||||
release_version: f.releaseVersion
|
||||
}
|
||||
: null,
|
||||
f.kind ?? undefined
|
||||
)}</td
|
||||
><td
|
||||
><details>
|
||||
<summary
|
||||
|
||||
Reference in New Issue
Block a user