feat: beta channels for core and plugins with one stable/beta release workflow

This commit is contained in:
LukeGus committed 2026-10-07 16:25:58 -05:00
1 parent 6a855c0fa8
commit f73c4b5ad6
55 files changed
+16200 -460

No files matched your search

+150 -43
View File
@@ -47,11 +47,19 @@ import "dotenv/config";
import { databaseLogger, apiLogger } from "../utils/logger.js";
import { getLocalVersion } from "../utils/app-version.js";
import {
compareSemver,
fetchGitHubAPI,
fetchLatestRelease,
isPrereleaseVersion,
REPO_NAME,
REPO_OWNER,
updateStatus,
} from "../utils/latest-release.js";
import {
getCoreChannel,
getStoredCoreChannel,
parseChannel,
setCoreChannel,
} from "../updates/update-channel.js";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
@@ -242,7 +250,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* /version:
* get:
* summary: Get version information
* description: Returns the running instance's version in localVersion. When the update check succeeds, remoteVersion is the latest GitHub release and version is its legacy alias, not the instance's version. Remote fields are omitted when the check is disabled, fails, or returns an unparseable release tag.
* description: Returns the running instance's version in localVersion. When the update check succeeds, remoteVersion is the newest GitHub release on the instance's update channel and version is its legacy alias, not the instance's version. Remote fields are omitted when the check is disabled, fails, or returns an unparseable release tag.
* tags:
* - General
* parameters:
@@ -271,9 +279,13 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* type: string
* description: Update comparison result, update_check_disabled when explicitly disabled, or unknown when the remote lookup fails or the release tag cannot be parsed.
* enum: [up_to_date, beta, requires_update, update_check_disabled, unknown]
* channel:
* type: string
* enum: [stable, beta]
* description: The update channel the check used. Always beta on a beta build.
* remoteVersion:
* type: string
* description: Latest GitHub release version. Present only when the update check succeeds.
* description: Newest release version on the channel. Present only when the update check succeeds.
* example: 2.8.0
* version:
* type: string
@@ -296,12 +308,8 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* html_url:
* type: string
* format: uri
* cached:
* type: boolean
* description: Whether the release lookup used a cached response. Present only when the update check succeeds.
* cache_age:
* type: number
* description: Age of the cached response in milliseconds, when available.
* prerelease:
* type: boolean
* 404:
* description: Local version not set.
*/
@@ -320,48 +328,31 @@ app.get("/version", authenticateJWT, async (req, res) => {
}
try {
const cacheKey = "latest_release";
const releaseData = await fetchGitHubAPI<GitHubRelease>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey,
);
const channel = await getCoreChannel(localVersion);
const latest = await fetchLatestRelease(channel);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
if (!latest) {
databaseLogger.warn("Remote version not found in GitHub response", {
operation: "version_check",
rawTag,
channel,
});
return res.json({ localVersion, status: "unknown" });
return res.json({ localVersion, status: "unknown", channel });
}
const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const response = {
status,
localVersion: localVersion,
version: remoteVersion,
remoteVersion: remoteVersion,
res.json({
status: updateStatus(localVersion, latest.version),
channel,
localVersion,
version: latest.version,
remoteVersion: latest.version,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
tag_name: latest.tagName,
name: latest.name,
published_at: latest.publishedAt,
html_url: latest.url,
prerelease: latest.prerelease,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
res.json(response);
});
} catch (err) {
databaseLogger.error("Version check failed", err, {
operation: "version_check",
@@ -370,6 +361,122 @@ app.get("/version", authenticateJWT, async (req, res) => {
}
});
/**
* @openapi
* /version/channel:
* get:
* summary: Get the update channel
* description: Which Termix releases this instance is told about. A beta build always reports beta; stored is the admin's choice for stable builds.
* tags:
* - General
* responses:
* 200:
* description: The channel.
* content:
* application/json:
* schema:
* type: object
* properties:
* channel:
* type: string
* enum: [stable, beta]
* stored:
* type: string
* enum: [stable, beta]
* runningBeta:
* type: boolean
* put:
* summary: Set the update channel
* description: Admin only. Switches which Termix releases update checks and the Updates page offer. It does not change the running build.
* tags:
* - General
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [channel]
* properties:
* channel:
* type: string
* enum: [stable, beta]
* responses:
* 200:
* description: The channel after the change.
* 400:
* description: Not a known channel.
*/
app.get("/version/channel", authenticateJWT, async (_req, res) => {
try {
const localVersion = getLocalVersion();
res.json({
channel: await getCoreChannel(localVersion),
stored: await getStoredCoreChannel(),
runningBeta: isPrereleaseVersion(localVersion ?? undefined),
});
} catch (error) {
databaseLogger.error("Failed to read the update channel", error, {
operation: "update_channel",
});
res.status(500).json({ error: "Failed to read the update channel" });
}
});
app.put("/version/channel", requireAdmin, async (req, res) => {
const channel = parseChannel(req.body?.channel);
if (!channel) {
return res.status(400).json({ error: "channel must be stable or beta" });
}
try {
await setCoreChannel(channel);
const localVersion = getLocalVersion();
res.json({
channel: await getCoreChannel(localVersion),
stored: channel,
runningBeta: isPrereleaseVersion(localVersion ?? undefined),
});
} catch (error) {
databaseLogger.error("Failed to save the update channel", error, {
operation: "update_channel",
});
res.status(500).json({ error: "Failed to save the update channel" });
}
});
/**
* @openapi
* /version/releases:
* get:
* summary: Get the newest stable and beta releases
* description: The newest stable Termix release and the newest beta, if one is newer than stable, for the Updates page.
* tags:
* - General
* responses:
* 200:
* description: The releases. Either may be null.
* 500:
* description: GitHub could not be reached.
*/
app.get("/version/releases", authenticateJWT, async (_req, res) => {
try {
const [stable, newest] = await Promise.all([
fetchLatestRelease("stable"),
fetchLatestRelease("beta"),
]);
res.json({
localVersion: getLocalVersion(),
stable,
beta: newest?.prerelease ? newest : null,
});
} catch (error) {
databaseLogger.error("Failed to read releases", error, {
operation: "version_releases",
});
res.status(500).json({ error: "Failed to read releases" });
}
});
/**
* @openapi
* /releases/rss:
+2
View File
@@ -509,6 +509,7 @@ async function initializeCompleteDatabase(): Promise<void> {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
pinned_version TEXT,
channel TEXT NOT NULL DEFAULT 'stable',
manifest_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plugins_registry_id ON plugins (registry_id);
@@ -1607,6 +1608,7 @@ const migrateSchema = () => {
// grants record whether a capability came from an admin or from bundling.
addColumnIfNotExists("plugins", "last_error", "TEXT");
addColumnIfNotExists("plugins", "pinned_version", "TEXT");
addColumnIfNotExists("plugins", "channel", "TEXT NOT NULL DEFAULT 'stable'");
addColumnIfNotExists(
"plugin_permission_grants",
"source",
+2
View File
@@ -1007,6 +1007,8 @@ export const plugins = mysqlTable(
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
/** stable | beta: which registry releases updates offer. */
channel: text("channel").notNull().default("stable"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
+2
View File
@@ -1007,6 +1007,8 @@ export const plugins = pgTable(
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
/** stable | beta: which registry releases updates offer. */
channel: text("channel").notNull().default("stable"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
+2
View File
@@ -1005,6 +1005,8 @@ export const plugins = sqliteTable(
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
/** stable | beta: which registry releases updates offer. */
channel: text("channel").notNull().default("stable"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
@@ -21,6 +21,7 @@ export interface PluginCreateInput {
lastError?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: string;
manifestJson: string;
}
@@ -34,6 +35,7 @@ export interface PluginUpdateInput {
lastError?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: string;
manifestJson?: string;
}
@@ -71,6 +73,7 @@ export class PluginRepository {
lastError: input.lastError ?? null,
autoUpdate: input.autoUpdate ?? false,
pinnedVersion: input.pinnedVersion ?? null,
channel: input.channel ?? "stable",
manifestJson: input.manifestJson,
installedAt: now,
updatedAt: now,
@@ -108,6 +111,7 @@ export class PluginRepository {
input.pinnedVersion === undefined
? existing.pinnedVersion
: input.pinnedVersion,
channel: input.channel ?? existing.channel,
manifestJson: input.manifestJson ?? existing.manifestJson,
updatedAt: now,
},
+92 -6
View File
@@ -18,6 +18,7 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js";
import { pluginFeatures, youtubeVideoId } from "../../plugins/manifest.js";
import {
isPluginChoice,
@@ -317,6 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
registryId: record.registryId ?? null,
autoUpdate: Boolean(record.autoUpdate),
pinnedVersion: record.pinnedVersion ?? null,
channel: record.channel ?? "stable",
description,
author,
repository,
@@ -1644,6 +1646,65 @@ router.post(
},
);
/**
* @openapi
* /plugins/channel:
* post:
* summary: Move every installed plugin to one update channel
* description: Sets the channel on every installed plugin. Nothing is installed here; the next update check offers what the channel allows. Going back to stable never downgrades a plugin that is on a beta.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [channel]
* properties:
* channel:
* type: string
* enum: [stable, beta]
* responses:
* 200:
* description: The plugins whose channel changed.
* 400:
* description: Not a known channel.
* 403:
* description: The caller lacks admin.plugins.manage.
* 409:
* description: This desktop is linked to a server, which manages its plugins.
*/
router.post(
"/channel",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const channel = parsePluginChannel(req.body?.channel);
if (!channel) {
res.status(400).json({ error: "channel must be stable or beta" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { setAllPluginChannels } = await import("../../plugins/manage.js");
const result = await setAllPluginChannels(channel);
await auditPluginAction(req, "update_plugin_options", "*", {
channel,
changed: result.changed,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to change the plugin channel",
"plugin_options",
);
}
},
);
/**
* @openapi
* /plugins/{id}/install:
@@ -1677,6 +1738,10 @@ router.post(
* properties:
* version:
* type: string
* channel:
* type: string
* enum: [stable, beta]
* description: The channel to follow. beta installs the newest beta when it is newer than stable.
* capabilities:
* type: array
* items:
@@ -1703,14 +1768,19 @@ router.post(
const pluginId = String(req.params.id);
const version = readVersion(req.body);
const capabilities = readCapabilities(req.body);
const channel =
req.body?.channel === undefined
? "stable"
: parsePluginChannel(req.body.channel);
if (
!ID_PATTERN.test(pluginId) ||
version === null ||
capabilities === null
capabilities === null ||
channel === null
) {
res
.status(400)
.json({ error: "Invalid plugin id, version or capabilities" });
.json({ error: "Invalid plugin id, version, channel or capabilities" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
@@ -1719,6 +1789,7 @@ router.post(
const userId = (req as AuthenticatedRequest).userId as string;
const result = await installPlugin(pluginId, {
version,
channel,
userId,
capabilities,
});
@@ -1891,7 +1962,7 @@ router.delete(
* /plugins/{id}/options:
* patch:
* summary: Change a plugin's update choices
* description: autoUpdate lets the background check apply new releases that ask for nothing new. pinned holds the plugin at its installed version, which also stops auto-update.
* description: autoUpdate lets the background check apply new releases that ask for nothing new. pinned holds the plugin at its installed version, which also stops auto-update. channel picks whether betas are offered; going back to stable keeps an installed beta until a stable release passes it.
* tags:
* - Plugins
* parameters:
@@ -1911,6 +1982,9 @@ router.delete(
* type: boolean
* pinned:
* type: boolean
* channel:
* type: string
* enum: [stable, beta]
* responses:
* 200:
* description: The saved choices.
@@ -1928,16 +2002,28 @@ router.patch(
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const { autoUpdate, pinned } = req.body ?? {};
const channel =
req.body?.channel === undefined
? undefined
: parsePluginChannel(req.body.channel);
if (
(autoUpdate !== undefined && typeof autoUpdate !== "boolean") ||
(pinned !== undefined && typeof pinned !== "boolean")
(pinned !== undefined && typeof pinned !== "boolean") ||
channel === null
) {
res.status(400).json({ error: "autoUpdate and pinned must be booleans" });
res.status(400).json({
error:
"autoUpdate and pinned must be booleans and channel stable or beta",
});
return;
}
try {
const { setPluginOptions } = await import("../../plugins/manage.js");
const result = await setPluginOptions(pluginId, { autoUpdate, pinned });
const result = await setPluginOptions(pluginId, {
autoUpdate,
pinned,
channel,
});
await auditPluginAction(req, "update_plugin_options", pluginId, result);
res.json({ id: pluginId, ...result });
} catch (error) {
+5 -1
View File
@@ -482,7 +482,11 @@ export class PluginLoader {
state.set(id, "done");
return false;
}
if (!semver.satisfies(dependency.manifest.version, range)) {
if (
!semver.satisfies(dependency.manifest.version, range, {
includePrerelease: true,
})
) {
blocked.set(
id,
`requires "${dependencyId}" ${range}, but ${dependency.manifest.version} is installed`,
+70 -11
View File
@@ -32,10 +32,14 @@ import {
OFFICIAL_REGISTRY_ID,
downloadRelease,
fetchRegistryIndex,
channelReleases,
findRelease,
getDownloadsDir,
getRegistryStatus,
isApiCompatible,
latestOnChannel,
parsePluginChannel,
type PluginChannel,
type RegistryIndex,
type RegistryVersion,
} from "./registry-index.js";
@@ -192,8 +196,8 @@ export async function isLinkedDesktop(): Promise<boolean> {
return Boolean(await getLink());
}
function latestCompatible(versions: RegistryVersion[]): RegistryVersion | null {
return versions.find((entry) => isApiCompatible(entry.api)) ?? null;
function channelOf(record: { channel?: string | null } | null | undefined) {
return parsePluginChannel(record?.channel) ?? "stable";
}
function addedCapabilities(
@@ -220,6 +224,7 @@ export interface RegistryListing {
features: string[];
versions: Array<{
version: string;
prerelease: boolean;
compatible: boolean;
capabilities: string[];
publishedAt?: string;
@@ -227,7 +232,11 @@ export interface RegistryListing {
notes?: ReleaseNotes;
size: number;
}>;
/** Newest release on the plugin's channel. */
latestVersion: string | null;
/** Newest compatible beta, when one is newer than stable. */
latestBeta: string | null;
channel: PluginChannel;
installed: boolean;
installedVersion: string | null;
updateAvailable: boolean;
@@ -259,7 +268,10 @@ export async function listRegistry(
plugins: index.plugins.map((plugin) => {
const record = records.get(plugin.id);
const live = loader.get(plugin.id);
const latest = latestCompatible(plugin.versions);
const channel = channelOf(record);
const latest = latestOnChannel(plugin, channel);
const latestBeta =
plugin.prereleases.find((entry) => isApiCompatible(entry.api)) ?? null;
const installedVersion =
live?.manifest.version ?? record?.version ?? null;
const updateAvailable = Boolean(
@@ -278,8 +290,9 @@ export async function listRegistry(
icon: plugin.icon,
videoId: plugin.videoId,
features: plugin.features,
versions: plugin.versions.map((entry) => ({
versions: channelReleases(plugin, "beta").map((entry) => ({
version: entry.version,
prerelease: Boolean(entry.prerelease),
compatible: isApiCompatible(entry.api),
capabilities: entry.capabilities,
publishedAt: entry.publishedAt,
@@ -288,6 +301,8 @@ export async function listRegistry(
size: entry.size,
})),
latestVersion: latest?.version ?? null,
latestBeta: latestBeta?.version ?? null,
channel,
installed: Boolean(record),
installedVersion: record ? installedVersion : null,
updateAvailable: Boolean(record) && updateAvailable,
@@ -501,7 +516,12 @@ function dependencyChain(pluginId: string): {
plugin.manifest.dependencies ?? {},
)) {
const found = loader.get(dependency);
if (found && !semver.satisfies(found.manifest.version, range)) {
if (
found &&
!semver.satisfies(found.manifest.version, range, {
includePrerelease: true,
})
) {
missing.push(`${dependency}@${range}`);
continue;
}
@@ -604,6 +624,8 @@ export async function setPluginStateUnlocked(
export interface InstallOptions {
version?: string;
/** Install only: the channel to follow from the start. */
channel?: PluginChannel;
userId: string | null;
acceptCapabilities?: boolean;
/**
@@ -637,6 +659,7 @@ function assertConsent(
async function requireRelease(
pluginId: string,
version: string | undefined,
channel: PluginChannel,
): Promise<{
index: RegistryIndex;
release: RegistryVersion;
@@ -654,7 +677,7 @@ async function requireRelease(
"REGISTRY_UNREACHABLE",
);
}
const found = findRelease(index, pluginId, version);
const found = findRelease(index, pluginId, version, channel);
if (!found) {
throw new PluginManageError(
version
@@ -674,7 +697,7 @@ async function requireRelease(
return {
index,
release: found.release,
latest: latestCompatible(found.plugin.versions),
latest: latestOnChannel(found.plugin, channel),
};
}
@@ -711,7 +734,12 @@ export function installPlugin(
);
}
const { release, latest } = await requireRelease(pluginId, options.version);
const channel = options.channel ?? "stable";
const { release, latest } = await requireRelease(
pluginId,
options.version,
channel,
);
assertConsent(options.capabilities, release.capabilities);
const pin = pinFor(release, latest, options.version);
await applyPin(pluginId, pin);
@@ -732,6 +760,7 @@ export function installPlugin(
registryId: OFFICIAL_REGISTRY_ID,
tier: plugin.source === "bundled" ? "bundled" : "official",
pinnedVersion: pin,
channel,
});
await grantDeclared(plugin, options.userId);
@@ -775,7 +804,11 @@ async function updateUnlocked(
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const { release, latest } = await requireRelease(pluginId, options.version);
const { release, latest } = await requireRelease(
pluginId,
options.version,
channelOf(record),
);
if (release.version === live.manifest.version) {
throw new PluginManageError(
`${pluginId} ${release.version} is already installed`,
@@ -951,8 +984,12 @@ export async function uninstallPluginUnlocked(
export function setPluginOptions(
pluginId: string,
options: { autoUpdate?: boolean; pinned?: boolean },
): Promise<{ autoUpdate: boolean; pinnedVersion: string | null }> {
options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel },
): Promise<{
autoUpdate: boolean;
pinnedVersion: string | null;
channel: PluginChannel;
}> {
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
@@ -970,15 +1007,37 @@ export function setPluginOptions(
const updated = await repository.update(pluginId, {
autoUpdate: options.autoUpdate,
pinnedVersion,
channel: options.channel,
});
await applyPin(pluginId, pinnedVersion);
return {
autoUpdate: Boolean(updated?.autoUpdate),
pinnedVersion: updated?.pinnedVersion ?? null,
channel: channelOf(updated),
};
});
}
/**
* Moves every installed plugin to one channel. Going back to stable never
* downgrades: a plugin on a beta stays there until a stable release passes it.
*/
export function setAllPluginChannels(
channel: PluginChannel,
): Promise<{ changed: string[] }> {
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const changed: string[] = [];
for (const record of await repository.listAll()) {
if (channelOf(record) === channel) continue;
await repository.update(record.id, { channel });
changed.push(record.id);
}
return { changed };
});
}
const MAX_CHANGELOG_BYTES = 512 * 1024;
/** A registry version's Markdown notes, parsed. Undefined when empty. */
+53 -8
View File
@@ -46,6 +46,14 @@ export interface RegistryVersion {
/** This version's CHANGELOG.md section, as Markdown. */
notes?: string;
publishedAt?: string;
/** Listed under the plugin's prereleases: a beta. */
prerelease?: boolean;
}
export type PluginChannel = "stable" | "beta";
export function parsePluginChannel(value: unknown): PluginChannel | null {
return value === "stable" || value === "beta" ? value : null;
}
export interface RegistryPlugin {
@@ -59,8 +67,13 @@ export interface RegistryPlugin {
/** Only ever a YouTube video id, never a link. */
videoId?: string;
features: string[];
/** Newest first. */
/** Stable releases, newest first. */
versions: RegistryVersion[];
/**
* Betas newer than the newest stable release, newest first. A separate
* list so cores from before channels never offer one.
*/
prereleases: RegistryVersion[];
}
export interface RegistryIndex {
@@ -126,11 +139,18 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
const entry = item as Record<string, unknown>;
const id = asString(entry.id);
if (!id || !ID_PATTERN.test(id)) continue;
const versions = (Array.isArray(entry.versions) ? entry.versions : [])
.map(parseVersion)
.filter((v): v is RegistryVersion => v !== null)
.sort((a, b) => semver.rcompare(a.version, b.version));
if (versions.length === 0) continue;
const parseList = (list: unknown) =>
(Array.isArray(list) ? list : [])
.map(parseVersion)
.filter((v): v is RegistryVersion => v !== null)
.sort((a, b) => semver.rcompare(a.version, b.version));
const versions = parseList(entry.versions).filter(
(v) => !semver.prerelease(v.version),
);
const prereleases = parseList(entry.prereleases)
.filter((v) => semver.prerelease(v.version))
.map((v) => ({ ...v, prerelease: true }));
if (versions.length === 0 && prereleases.length === 0) continue;
plugins.push({
id,
name: asString(entry.name) ?? id,
@@ -142,6 +162,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
videoId: youtubeVideoId(entry.video) ?? undefined,
features: pluginFeatures(entry.features),
versions,
prereleases,
});
}
@@ -315,16 +336,40 @@ export function resetRegistryCache(): void {
lastError = null;
}
/** Every release a channel may install, newest first. */
export function channelReleases(
plugin: RegistryPlugin,
channel: PluginChannel,
): RegistryVersion[] {
if (channel === "stable") return plugin.versions;
return [...plugin.prereleases, ...plugin.versions].sort((a, b) =>
semver.rcompare(a.version, b.version),
);
}
/** The newest release on a channel this build can run. */
export function latestOnChannel(
plugin: RegistryPlugin,
channel: PluginChannel,
): RegistryVersion | null {
return (
channelReleases(plugin, channel).find((entry) =>
isApiCompatible(entry.api),
) ?? null
);
}
export function findRelease(
index: RegistryIndex,
pluginId: string,
version?: string,
channel: PluginChannel = "stable",
): { plugin: RegistryPlugin; release: RegistryVersion } | null {
const plugin = index.plugins.find((entry) => entry.id === pluginId);
if (!plugin) return null;
const release = version
? plugin.versions.find((entry) => entry.version === version)
: plugin.versions.find((entry) => isApiCompatible(entry.api));
? channelReleases(plugin, "beta").find((entry) => entry.version === version)
: latestOnChannel(plugin, channel);
if (!release) return null;
return { plugin, release };
}
+14 -3
View File
@@ -229,7 +229,12 @@ export function getServiceImplementation<T extends object>(
): T | undefined {
const registration = lookup(service, name);
if (!registration) return undefined;
if (!semver.satisfies(registration.version, versionRange)) return undefined;
if (
!semver.satisfies(registration.version, versionRange, {
includePrerelease: true,
})
)
return undefined;
return registration.implementation as T;
}
@@ -267,7 +272,9 @@ export function resolveRequirements(
const providers = [...(services.get(requirement.service)?.values() ?? [])];
const registration =
providers.find((candidate) =>
semver.satisfies(candidate.version, requirement.versionRange),
semver.satisfies(candidate.version, requirement.versionRange, {
includePrerelease: true,
}),
) ?? providers[0];
if (!registration) {
@@ -280,7 +287,11 @@ export function resolveRequirements(
continue;
}
if (!semver.satisfies(registration.version, requirement.versionRange)) {
if (
!semver.satisfies(registration.version, requirement.versionRange, {
includePrerelease: true,
})
) {
recordUnsatisfied(
requirement,
`requires service "${requirement.service}" ${requirement.versionRange}, but ${registration.pluginId} provides ${registration.version}`,
@@ -169,6 +169,7 @@ const manageMock = vi.hoisted(() => ({
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
getPluginChangelog: vi.fn(),
deletePluginData: vi.fn(),
@@ -730,6 +731,7 @@ describe("plugins route", () => {
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
version: "1.0.0",
channel: "stable",
userId: "admin-1",
capabilities: ["kv:own"],
});
@@ -741,6 +743,68 @@ describe("plugins route", () => {
);
});
it("installs on the beta channel when asked", async () => {
manageMock.installPlugin.mockResolvedValue({
id: "docker",
version: "1.1.0-beta.1",
state: "active",
});
const res = await post("/plugins/docker/install", {
channel: "beta",
capabilities: ["kv:own"],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith(
"docker",
expect.objectContaining({ channel: "beta" }),
);
expect(
(
await post("/plugins/docker/install", {
channel: "nightly",
capabilities: [],
})
).status,
).toBe(400);
});
it("moves every plugin to one channel", async () => {
manageMock.setAllPluginChannels.mockResolvedValue({
changed: ["docker"],
});
const res = await post("/plugins/channel", { channel: "beta" });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ changed: ["docker"] });
expect(manageMock.setAllPluginChannels).toHaveBeenCalledWith("beta");
expect((await post("/plugins/channel", { channel: "x" })).status).toBe(
400,
);
});
it("saves a plugin's channel and refuses an unknown one", async () => {
manageMock.setPluginOptions.mockResolvedValue({
autoUpdate: false,
pinnedVersion: null,
channel: "beta",
});
const patch = (body: unknown) =>
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-test-user-id": "admin-1",
},
body: JSON.stringify(body),
});
expect((await patch({ channel: "beta" })).status).toBe(200);
expect(manageMock.setPluginOptions).toHaveBeenCalledWith("docker", {
autoUpdate: undefined,
pinned: undefined,
channel: "beta",
});
expect((await patch({ channel: "nightly" })).status).toBe(400);
});
it("rejects a bad id or version before touching anything", async () => {
expect((await post("/plugins/..%2Fx/install")).status).toBe(400);
expect(
@@ -865,6 +929,7 @@ describe("plugins route", () => {
post("/plugins/docker/install", {}, user),
post("/plugins/docker/update", {}, user),
post("/plugins/update-all", {}, user),
post("/plugins/channel", { channel: "beta" }, user),
fetch(`${baseUrl}/plugins/docker`, { method: "DELETE", headers }),
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
@@ -21,7 +21,10 @@ const route = ast.statements.find(
) as ts.ExpressionStatement;
const callback = (route.expression as ts.CallExpression).arguments[2];
async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
async function invoke(
fetchLatestRelease: ReturnType<typeof vi.fn>,
channel = "stable",
) {
const handler = runInNewContext(
ts.transpileModule(`(${callback.getText(ast)})`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
@@ -29,10 +32,9 @@ async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
{
getLocalVersion: () => "2.7.1",
databaseLogger: { warn: vi.fn(), error: vi.fn() },
fetchGitHubAPI,
REPO_OWNER: "Termix-SSH",
REPO_NAME: "Termix",
compareSemver: () => -1,
fetchLatestRelease,
getCoreChannel: async () => channel,
updateStatus: () => "requires_update",
},
);
const res = { json: vi.fn(), status: vi.fn(), send: vi.fn() };
@@ -50,23 +52,33 @@ it("keeps the installed version when the update service is unreachable", async (
expect(res.status).not.toHaveBeenCalled();
});
it("keeps the installed version if the release has no parseable version", async () => {
const res = await invoke(
vi.fn().mockResolvedValue({ data: { tag_name: "nightly" } }),
);
const res = await invoke(vi.fn().mockResolvedValue(null));
expect(res.json).toHaveBeenCalledWith({
localVersion: "2.7.1",
status: "unknown",
channel: "stable",
});
});
it("still reports a newer release when the check succeeds", async () => {
const res = await invoke(
vi.fn().mockResolvedValue({ data: { tag_name: "2.8.0" } }),
vi.fn().mockResolvedValue({ version: "2.8.0", tagName: "v2.8.0" }),
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
localVersion: "2.7.1",
status: "requires_update",
remoteVersion: "2.8.0",
channel: "stable",
}),
);
});
it("checks the channel the instance is on", async () => {
const fetchLatest = vi
.fn()
.mockResolvedValue({ version: "2.8.0-beta.1", tagName: "v2.8.0-beta.1" });
const res = await invoke(fetchLatest, "beta");
expect(fetchLatest).toHaveBeenCalledWith("beta");
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ channel: "beta", remoteVersion: "2.8.0-beta.1" }),
);
});
+120 -9
View File
@@ -160,19 +160,22 @@ const DEFAULT_CAPS = ["hosts:read", "kv:own"];
function registryEntry(
id: string,
versions: Array<{ version: string; capabilities?: string[] }>,
prereleases: Array<{ version: string; capabilities?: string[] }> = [],
) {
const toEntry = (v: { version: string; capabilities?: string[] }) => ({
version: v.version,
api: "1",
url: `https://example.com/${id}-${v.version}.tmxplug`,
sha256: "a".repeat(64),
signature: "sig",
size: 1,
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
});
return {
id,
name: id,
versions: versions.map((v) => ({
version: v.version,
api: "1",
url: `https://example.com/${id}-${v.version}.tmxplug`,
sha256: "a".repeat(64),
signature: "sig",
size: 1,
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
})),
versions: versions.map(toEntry),
prereleases: prereleases.map(toEntry),
};
}
@@ -407,6 +410,114 @@ describe("updates", () => {
});
});
describe("beta channel", () => {
it("offers betas only to a plugin on the beta channel", async () => {
createFixturePlugin({ id: "docker", root: bundled });
registry.index.plugins = [
registryEntry(
"docker",
[{ version: "1.0.0" }],
[{ version: "1.1.0-beta.1" }],
),
];
await boot();
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
channel: "stable",
latestVersion: "1.0.0",
latestBeta: "1.1.0-beta.1",
updateAvailable: false,
});
expect(
(await manage.listRegistry()).plugins[0].versions.map((v) => [
v.version,
v.prerelease,
]),
).toEqual([
["1.1.0-beta.1", true],
["1.0.0", false],
]);
await manage.setPluginOptions("docker", { channel: "beta" });
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
channel: "beta",
latestVersion: "1.1.0-beta.1",
updateAvailable: true,
});
});
it("keeps an installed beta after going back to stable", async () => {
createFixturePlugin({
id: "docker",
root: bundled,
manifestOverrides: { version: "1.1.0-beta.1" },
});
registry.index.plugins = [
registryEntry(
"docker",
[{ version: "1.0.0" }],
[{ version: "1.1.0-beta.1" }],
),
];
await boot();
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
channel: "stable",
installedVersion: "1.1.0-beta.1",
updateAvailable: false,
});
const result = await manage.updateAllPlugins({ userId: "admin" });
expect(result.updated).toEqual([]);
expect(registry.download).not.toHaveBeenCalled();
registry.index.plugins = [
registryEntry("docker", [{ version: "1.1.0" }, { version: "1.0.0" }]),
];
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
latestVersion: "1.1.0",
updateAvailable: true,
});
});
it("installs the newest beta when asked to follow betas", async () => {
registry.index.plugins = [
registryEntry(
"notes",
[{ version: "1.0.0" }],
[{ version: "1.1.0-beta.2" }],
),
];
registry.download.mockImplementation(() =>
stageArtifact("notes", { version: "1.1.0-beta.2" }),
);
await boot();
const result = await manage.installPlugin("notes", {
userId: "admin",
channel: "beta",
capabilities: DEFAULT_CAPS,
});
expect(result.version).toBe("1.1.0-beta.2");
expect(db.rows.get("notes")).toMatchObject({
channel: "beta",
pinnedVersion: null,
});
});
it("moves every installed plugin to one channel", async () => {
createFixturePlugin({ id: "docker", root: bundled });
createFixturePlugin({ id: "notes", root: bundled });
await boot();
expect((await manage.setAllPluginChannels("beta")).changed.sort()).toEqual([
"docker",
"notes",
]);
expect(db.rows.get("docker")?.channel).toBe("beta");
expect((await manage.setAllPluginChannels("beta")).changed).toEqual([]);
});
});
describe("dependencies", () => {
it("starts a dependency first and stops dependents first", async () => {
createFixturePlugin({ id: "base", root: bundled });
@@ -29,6 +29,7 @@ import {
fetchRegistryIndex,
fetchRegistryStats,
findRelease,
latestOnChannel,
getStatsUrl,
isApiCompatible,
parseRegistryIndex,
@@ -188,6 +189,63 @@ describe("findRelease", () => {
});
});
describe("prereleases", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "docker",
versions: [
version({ version: "1.1.0" }),
version({ version: "1.2.0-beta.1" }),
],
prereleases: [
version({ version: "1.2.0-beta.1" }),
version({ version: "1.2.0-beta.10" }),
version({ version: "1.3.0" }),
],
},
{ id: "only-beta", prereleases: [version({ version: "0.1.0-beta.1" })] },
],
});
const docker = index.plugins[0];
it("keeps betas out of the stable list and stable out of betas", () => {
expect(docker.versions.map((v) => v.version)).toEqual(["1.1.0"]);
expect(docker.prereleases.map((v) => v.version)).toEqual([
"1.2.0-beta.10",
"1.2.0-beta.1",
]);
expect(docker.prereleases.every((v) => v.prerelease)).toBe(true);
expect(index.plugins[1].versions).toEqual([]);
});
it("picks by channel and finds a beta by exact version", () => {
expect(latestOnChannel(docker, "stable")?.version).toBe("1.1.0");
expect(latestOnChannel(docker, "beta")?.version).toBe("1.2.0-beta.10");
expect(findRelease(index, "docker")?.release.version).toBe("1.1.0");
expect(
findRelease(index, "docker", undefined, "beta")?.release.version,
).toBe("1.2.0-beta.10");
expect(findRelease(index, "docker", "1.2.0-beta.1")?.release.version).toBe(
"1.2.0-beta.1",
);
expect(findRelease(index, "only-beta")).toBeNull();
});
it("prefers a newer stable over an older beta", () => {
const later = parseRegistryIndex({
plugins: [
{
id: "docker",
versions: [version({ version: "1.2.0" })],
prereleases: [version({ version: "1.2.0-beta.3" })],
},
],
});
expect(latestOnChannel(later.plugins[0], "beta")?.version).toBe("1.2.0");
});
});
describe("fetchRegistryIndex", () => {
const body = JSON.stringify({
plugins: [{ id: "docker", versions: [version()] }],
@@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendCoreAlert = vi.hoisted(() => vi.fn(async () => {}));
const latest = vi.hoisted(() => ({
value: null as null | { version: string; url: string },
value: null as null | { version: string; url: string; prerelease?: boolean },
}));
const channel = vi.hoisted(() => ({ value: "stable", asked: [] as string[] }));
const version = vi.hoisted(() => ({ value: "2.9.0" as string | null }));
const disabled = vi.hoisted(() => ({ value: false }));
@@ -13,7 +14,13 @@ vi.mock("../../utils/app-version.js", () => ({
}));
vi.mock("../../utils/latest-release.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../utils/latest-release.js")>()),
fetchLatestRelease: async () => latest.value,
fetchLatestRelease: async (wanted: string) => {
channel.asked.push(wanted);
return latest.value;
},
}));
vi.mock("../../updates/update-channel.js", () => ({
getCoreChannel: async () => channel.value,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
@@ -31,6 +38,8 @@ beforeEach(() => {
sendCoreAlert.mockClear();
version.value = "2.9.0";
disabled.value = false;
channel.value = "stable";
channel.asked = [];
latest.value = {
version: "2.9.1",
url: "https://github.com/Termix-SSH/Termix/releases/tag/v2.9.1",
@@ -60,4 +69,29 @@ describe("desktop update check", () => {
await checkForDesktopUpdate();
expect(sendCoreAlert).not.toHaveBeenCalled();
});
it("follows the beta channel", async () => {
channel.value = "beta";
latest.value = {
version: "2.9.1-beta.2",
url: "https://github.com/Termix-SSH/Termix/releases/tag/v2.9.1-beta.2",
prerelease: true,
};
await checkForDesktopUpdate();
expect(channel.asked).toEqual(["beta"]);
expect(sendCoreAlert).toHaveBeenCalledWith(
expect.objectContaining({
title: "Termix 2.9.1-beta.2 is available",
dedupeKey: "update:2.9.1-beta.2",
}),
);
});
it("does not offer the matching stable as older than its own beta", async () => {
version.value = "2.9.1-beta.2";
await checkForDesktopUpdate();
expect(sendCoreAlert).toHaveBeenCalledWith(
expect.objectContaining({ dedupeKey: "update:2.9.1" }),
);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
compareSemver,
isPrereleaseVersion,
normalizeVersion,
updateStatus,
} from "../../utils/latest-release.js";
describe("normalizeVersion", () => {
it("reads tags and short versions and keeps the prerelease part", () => {
expect(normalizeVersion("v2.8")).toBe("2.8.0");
expect(normalizeVersion("release-2.9.1-tag")).toBe("2.9.1-tag");
expect(normalizeVersion("v26.11.0-beta.2")).toBe("26.11.0-beta.2");
expect(normalizeVersion("nightly")).toBeNull();
});
});
describe("compareSemver", () => {
it("orders betas below their stable release and by number", () => {
expect(compareSemver("26.11.0-beta.1", "26.11.0")).toBe(-1);
expect(compareSemver("26.11.0-beta.2", "26.11.0-beta.10")).toBe(-1);
expect(compareSemver("26.11.0-beta.1", "26.10.3")).toBe(1);
expect(compareSemver("v2.9.0", "2.9")).toBe(0);
expect(compareSemver("x", "2.9.0")).toBeNull();
});
});
describe("updateStatus", () => {
it("calls a newer release on the channel an update", () => {
expect(updateStatus("26.10.0", "26.10.1")).toBe("requires_update");
expect(updateStatus("26.11.0-beta.1", "26.11.0-beta.2")).toBe(
"requires_update",
);
expect(updateStatus("26.11.0-beta.2", "26.11.0")).toBe("requires_update");
});
it("marks betas and builds ahead of the newest release as beta", () => {
expect(updateStatus("26.11.0-beta.2", "26.11.0-beta.2")).toBe("beta");
expect(updateStatus("26.11.0-beta.2", "26.10.0")).toBe("beta");
expect(updateStatus("26.11.0", "26.10.0")).toBe("beta");
expect(updateStatus("26.10.0", "26.10.0")).toBe("up_to_date");
expect(updateStatus("26.10.0", null)).toBe("up_to_date");
});
it("knows a prerelease", () => {
expect(isPrereleaseVersion("26.11.0-beta.1")).toBe(true);
expect(isPrereleaseVersion("26.11.0")).toBe(false);
});
});
+2 -1
View File
@@ -6,6 +6,7 @@
import { sendCoreAlert } from "../notify/core-notify.js";
import { getLocalVersion } from "../utils/app-version.js";
import { compareSemver, fetchLatestRelease } from "../utils/latest-release.js";
import { getCoreChannel } from "./update-channel.js";
import { systemLogger } from "../utils/logger.js";
import {
createCurrentUserPreferenceRepository,
@@ -33,7 +34,7 @@ export async function checkForDesktopUpdate(): Promise<void> {
const current = getLocalVersion();
if (!current || (await checkDisabled())) return;
const latest = await fetchLatestRelease();
const latest = await fetchLatestRelease(await getCoreChannel(current));
if (!latest) return;
const comparison = compareSemver(current, latest.version);
if (comparison === null || comparison >= 0) return;
+34
View File
@@ -0,0 +1,34 @@
/**
* Which Termix releases this instance hears about. The server can't swap its
* own image or installer, so the channel only steers update checks and the
* Updates page. A beta build always counts as beta.
*/
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { getLocalVersion } from "../utils/app-version.js";
import {
isPrereleaseVersion,
type UpdateChannel,
} from "../utils/latest-release.js";
export const CORE_CHANNEL_KEY = "core_update_channel";
export function parseChannel(value: unknown): UpdateChannel | null {
return value === "stable" || value === "beta" ? value : null;
}
export async function getStoredCoreChannel(): Promise<UpdateChannel> {
const stored = await createCurrentSettingsRepository().get(CORE_CHANNEL_KEY);
return parseChannel(stored) ?? "stable";
}
export async function getCoreChannel(
localVersion: string | null = getLocalVersion(),
): Promise<UpdateChannel> {
if (localVersion && isPrereleaseVersion(localVersion)) return "beta";
return getStoredCoreChannel();
}
export async function setCoreChannel(channel: UpdateChannel): Promise<void> {
await createCurrentSettingsRepository().set(CORE_CHANNEL_KEY, channel);
}
+74 -26
View File
@@ -1,3 +1,4 @@
import semver from "semver";
import { databaseLogger } from "./logger.js";
import { getProxyAgent } from "./proxy-agent.js";
import type {
@@ -36,29 +37,32 @@ class GitHubCache {
const githubCache = new GitHubCache();
function parseSemver(
version: string | undefined,
): [number, number, number] | null {
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
/**
* A release tag or version as full semver, keeping any prerelease part:
* "v2.8" is 2.8.0 and "v26.11.0-beta.2" stays a prerelease.
*/
export function normalizeVersion(version: string | undefined): string | null {
const match = String(version || "").match(
/(\d+)\.(\d+)(?:\.(\d+))?(-[0-9A-Za-z.-]+)?/,
);
if (!match) return null;
const normalized = `${match[1]}.${match[2]}.${match[3] || 0}${match[4] || ""}`;
return semver.valid(normalized);
}
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
export function isPrereleaseVersion(version: string | undefined): boolean {
const normalized = normalizeVersion(version);
return Boolean(normalized && semver.prerelease(normalized));
}
export function compareSemver(
a: string | undefined,
b: string | undefined,
): number | null {
const parsedA = parseSemver(a);
const parsedB = parseSemver(b);
const parsedA = normalizeVersion(a);
const parsedB = normalizeVersion(b);
if (!parsedA || !parsedB) return null;
for (let i = 0; i < 3; i += 1) {
if (parsedA[i] > parsedB[i]) return 1;
if (parsedA[i] < parsedB[i]) return -1;
}
return 0;
return semver.compare(parsedA, parsedB);
}
const GITHUB_API_BASE = "https://api.github.com";
@@ -116,28 +120,72 @@ export async function fetchGitHubAPI<T>(
}
}
export type UpdateChannel = "stable" | "beta";
export interface LatestRelease {
version: string;
tagName: string;
name: string;
url: string;
publishedAt: string;
prerelease: boolean;
notes: string;
}
/** The newest Termix release on GitHub, cached for 30 minutes. */
export async function fetchLatestRelease(): Promise<LatestRelease | null> {
const release = await fetchGitHubAPI<GitHubRelease>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release",
);
const rawTag = release.data.tag_name || release.data.name || "";
const version = rawTag.match(/(\d+\.\d+(\.\d+)?)/)?.[1];
function toLatestRelease(release: GitHubRelease): LatestRelease | null {
const version = normalizeVersion(release.tag_name || release.name || "");
if (!version) return null;
return {
version,
tagName: release.data.tag_name,
name: release.data.name,
url: release.data.html_url,
publishedAt: release.data.published_at,
tagName: release.tag_name,
name: release.name,
url: release.html_url,
publishedAt: release.published_at,
prerelease: Boolean(release.prerelease) || !!semver.prerelease(version),
notes: (release.body || "").slice(0, 20_000),
};
}
/**
* The newest Termix release on GitHub, cached for 30 minutes. On the beta
* channel a newer beta wins over the newest stable release.
*/
export async function fetchLatestRelease(
channel: UpdateChannel = "stable",
): Promise<LatestRelease | null> {
if (channel === "stable") {
const release = await fetchGitHubAPI<GitHubRelease>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release",
);
return toLatestRelease(release.data);
}
const releases = await fetchGitHubAPI<GitHubRelease[]>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?per_page=30`,
"latest_releases",
);
let newest: LatestRelease | null = null;
for (const release of releases.data) {
if (release.draft) continue;
const parsed = toLatestRelease(release);
if (parsed && (!newest || semver.gt(parsed.version, newest.version))) {
newest = parsed;
}
}
return newest;
}
export type UpdateStatus = "up_to_date" | "beta" | "requires_update";
/** How the running version compares with the newest release on its channel. */
export function updateStatus(
localVersion: string,
latestVersion: string | null | undefined,
): UpdateStatus {
const comparison = latestVersion
? compareSemver(localVersion, latestVersion)
: null;
if (comparison !== null && comparison < 0) return "requires_update";
if (isPrereleaseVersion(localVersion) || (comparison ?? 0) > 0) return "beta";
return "up_to_date";
}
+22 -2
View File
@@ -247,6 +247,7 @@ export interface PluginSummary {
registryId?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: PluginChannel;
description?: string;
author?: string;
repository?: string;
@@ -269,8 +270,12 @@ function announcePluginsChanged(): void {
window.dispatchEvent(new CustomEvent(PLUGINS_CHANGED_EVENT));
}
export type PluginChannel = "stable" | "beta";
export interface RegistryPluginVersion {
version: string;
/** A beta, offered only to plugins on the beta channel. */
prerelease?: boolean;
compatible: boolean;
capabilities: string[];
publishedAt?: string;
@@ -290,7 +295,11 @@ export interface RegistryPluginEntry {
videoId?: string;
features?: string[];
versions: RegistryPluginVersion[];
/** Newest release on the plugin's channel. */
latestVersion: string | null;
/** Newest beta, when one is newer than stable. */
latestBeta?: string | null;
channel?: PluginChannel;
installed: boolean;
installedVersion: string | null;
updateAvailable: boolean;
@@ -443,8 +452,12 @@ export async function retryPlugin(pluginId: string): Promise<void> {
export async function setPluginOptions(
pluginId: string,
options: { autoUpdate?: boolean; pinned?: boolean },
): Promise<{ autoUpdate: boolean; pinnedVersion: string | null }> {
options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel },
): Promise<{
autoUpdate: boolean;
pinnedVersion: string | null;
channel: PluginChannel;
}> {
const response = await rbacApi.patch(
`${pluginPath(pluginId)}/options`,
options,
@@ -452,6 +465,13 @@ export async function setPluginOptions(
return response.data;
}
export async function setAllPluginChannels(
channel: PluginChannel,
): Promise<{ changed: string[] }> {
const response = await rbacApi.post("/plugins/channel", { channel });
return response.data;
}
export async function getPluginData(
pluginId: string,
): Promise<PluginDataSummary> {
+41
View File
@@ -42,6 +42,7 @@ export async function getReleasesRSS(
export interface VersionInfo {
status?: "up_to_date" | "requires_update" | "beta" | "unknown";
channel?: "stable" | "beta";
/** Same value as remoteVersion; the endpoint sends both. */
version?: string;
localVersion?: string;
@@ -81,6 +82,46 @@ export async function getVersionInfo(checkRemote = true): Promise<VersionInfo> {
}
}
export type UpdateChannel = "stable" | "beta";
export interface UpdateChannelState {
channel: UpdateChannel;
/** The admin's choice; a beta build reports beta whatever it is. */
stored: UpdateChannel;
runningBeta: boolean;
}
export async function getUpdateChannel(): Promise<UpdateChannelState> {
const response = await authApi.get("/version/channel");
return response.data;
}
export async function setUpdateChannel(
channel: UpdateChannel,
): Promise<UpdateChannelState> {
const response = await authApi.put("/version/channel", { channel });
return response.data;
}
export interface ReleaseInfo {
version: string;
tagName: string;
name: string;
url: string;
publishedAt: string;
prerelease: boolean;
notes: string;
}
export async function getChannelReleases(): Promise<{
localVersion: string | null;
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
}> {
const response = await authApi.get("/version/releases");
return response.data;
}
// ============================================================================
// DATABASE HEALTH
// ============================================================================
+19 -5
View File
@@ -87,15 +87,29 @@ export function describeEnvironment(
}
/** A bug report on the Termix core repo. */
export function reportCoreIssueUrl(
termixVersion?: string,
template: "bug_report.yml" | "beta_feedback.yml" = "bug_report.yml",
): string {
export function reportCoreIssueUrl(termixVersion?: string): string {
return buildIssueUrl(CORE_REPO_URL, {
template,
template: "bug_report.yml",
fields: {
"termix-version": termixVersion,
environment: describeEnvironment(),
},
});
}
/** Beta feedback on the core repo, listing every plugin running a beta. */
export function reportBetaFeedbackUrl(
termixVersion?: string,
betaPlugins: Array<{ id: string; version: string }> = [],
): string {
return buildIssueUrl(CORE_REPO_URL, {
template: "beta_feedback.yml",
fields: {
"termix-version": termixVersion,
environment: describeEnvironment(),
"beta-plugins": betaPlugins
.map((plugin) => `${plugin.id} ${plugin.version}`)
.join("\n"),
},
});
}
+56 -7
View File
@@ -1688,10 +1688,6 @@
"twoFaOn": "On",
"twoFaOff": "Off",
"versionLabel": "Version",
"betaProgramTitle": "Beta Program",
"betaProgramDescription": "Try upcoming features early with the weekly rolling :beta Docker tag. Unstable, not for production.",
"betaProgramFeedback": "Found a bug? Report it here.",
"betaProgramLearnMore": "Learn More",
"cliTitle": "Termix CLI",
"cliDescription": "Open terminals, run commands and move files from your own shell.",
"cliLearnMore": "Learn More",
@@ -1826,7 +1822,13 @@
"linkedProfileDescription": "This device uses your account on the server and keeps a copy of your data, so it still works offline.",
"manageSync": "Manage sync",
"setUpSync": "Link to a server",
"settingsTabsAndShortcuts": "Tabs and Shortcuts"
"settingsTabsAndShortcuts": "Tabs and Shortcuts",
"onBetaTitle": "You are on a beta",
"onBetaDescription": "Thanks for testing. Tell us what breaks.",
"tryBetaTitle": "Try new features early",
"tryBetaDescription": "Join the beta program in Updates.",
"sendBetaFeedback": "Send beta feedback",
"openUpdates": "Updates"
}
}
},
@@ -2168,7 +2170,22 @@
"reportIssue": "Report an issue",
"reportTo": "Report to {{author}}",
"requestFeature": "Request a feature",
"openSettings": "Open its settings"
"openSettings": "Open its settings",
"beta": {
"badge": "Beta",
"badgeHint": "This is a beta version. It can have bugs.",
"try": "Try beta {{version}}",
"confirmTitle": "Use betas for {{name}}?",
"confirmBody": "Betas get new features and fixes first, but they can have bugs. You can switch back to stable at any time.",
"confirmInstall": "Install {{version}}",
"confirmSwitch": "Use betas",
"leftWhileOnBeta": "{{name}} stays on {{version}} until a stable release is newer.",
"channel": "Beta versions",
"channelHint": "Get new versions early. They can have bugs.",
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
"versionLabel": "{{version}} (beta)",
"sendFeedback": "Send beta feedback"
}
}
},
"settings": {
@@ -2207,7 +2224,39 @@
"instanceSettings": "Server settings",
"nothingHere": "Nothing to set here.",
"searchPage": "Search this page",
"noPageMatches": "Nothing on this page matches."
"noPageMatches": "Nothing on this page matches.",
"updates": {
"title": "Updates",
"betaProgram": "Beta program",
"betaIntro": "Betas are early builds of the next Termix version. They come out on Tuesdays and Fridays when there is something new to try. They can have bugs, and your feedback is what makes the stable release better.",
"termixBetas": "Termix betas",
"termixBetasHint": "Tell me about new betas, not only stable releases.",
"runningBetaBuild": "You are running a beta build. To leave, switch back to a stable image or installer.",
"howToInstall": "How to get the beta",
"desktopInstall": "Download the beta installer and install it over this one.",
"downloadBeta": "Download {{version}}",
"dockerInstall": "Change your image to the beta tag and restart the container:",
"copy": "Copy",
"copied": "Copied",
"copyFailed": "Could not copy",
"learnMore": "Learn more about betas",
"feedback": "Feedback",
"feedbackHint": "Found a bug or have an idea? It goes straight to the developers with your version filled in.",
"sendFeedback": "Send beta feedback",
"releases": "Releases",
"thisVersion": "This version",
"newestStable": "Newest stable",
"newestBeta": "Newest beta",
"noRelease": "None right now",
"pluginBetas": "Plugin betas",
"pluginBetasIntro": "Each plugin can use betas on its own. To try one, open it and press Try beta.",
"openPlugins": "Open Plugins",
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
"backToStable": "Back to stable",
"allPluginsBeta": "Use betas for all plugins",
"allPluginsStable": "All plugins back to stable",
"saveFailed": "Could not save the change"
}
},
"managerCard": {
"refresh": "Refresh",
+64 -1
View File
@@ -6,6 +6,7 @@ import {
Database,
Download,
ExternalLink,
FlaskConical,
Lightbulb,
Pin,
RotateCcw,
@@ -26,12 +27,15 @@ import {
} from "@/components/select";
import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
import {
BetaBadge,
CapabilityRow,
InstallCountFact,
PluginIconBox,
UnverifiedBadge,
} from "./plugin-bits";
import {
betaFeedbackUrl,
betaToTry,
describeContributions,
formatBytes,
orderByRisk,
@@ -80,6 +84,10 @@ export function PluginDetail({
import.meta.env.VITE_APP_VERSION || undefined,
);
const featureUrl = requestFeatureUrl(plugin);
const feedbackUrl = plugin.isBeta
? betaFeedbackUrl(plugin, import.meta.env.VITE_APP_VERSION || undefined)
: null;
const tryableBeta = betaToTry(plugin);
return (
<div
@@ -98,6 +106,7 @@ export function PluginDetail({
</span>
<Facts className="text-[11px] text-muted-foreground/70">
{plugin.unverified && <UnverifiedBadge />}
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
{plugin.version ? (
<span>v{plugin.version}</span>
@@ -135,6 +144,18 @@ export function PluginDetail({
})}
</Button>
)}
{tryableBeta && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={busy || locked}
onClick={() => void manager.tryBeta(plugin)}
>
<FlaskConical className="size-3.5" />
{t("plugins.manager.beta.try", { version: tryableBeta })}
</Button>
)}
{plugin.installed ? (
<>
{failed ? (
@@ -299,7 +320,11 @@ export function PluginDetail({
? t("plugins.manager.latestVersion", {
version: v.version,
})
: v.version}
: v.prerelease
? t("plugins.manager.beta.versionLabel", {
version: v.version,
})
: v.version}
</SelectItem>
))}
</SelectContent>
@@ -346,6 +371,30 @@ export function PluginDetail({
}
/>
</div>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
<FlaskConical className="size-3 text-muted-foreground" />
{t("plugins.manager.beta.channel")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{plugin.channel === "stable" && plugin.isBeta
? t("plugins.manager.beta.waitingForStable", {
version: plugin.version,
})
: t("plugins.manager.beta.channelHint")}
</span>
</div>
<FakeSwitch
checked={plugin.channel === "beta"}
disabled={busy || locked}
onChange={(v) =>
void (v
? manager.tryBeta(plugin)
: manager.leaveBeta(plugin))
}
/>
</div>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
@@ -401,6 +450,20 @@ export function PluginDetail({
</a>
)}
{feedbackUrl && (
<a
href={feedbackUrl}
target="_blank"
rel="noreferrer"
className={LINK_ROW}
>
<FlaskConical className="size-3.5 shrink-0" />
<span className="truncate">
{t("plugins.manager.beta.sendFeedback")}
</span>
</a>
)}
{featureUrl && (
<a
href={featureUrl}
+2
View File
@@ -35,6 +35,7 @@ import {
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils";
import {
BetaBadge,
InstallCountFact,
PluginIconBox,
UnverifiedBadge,
@@ -469,6 +470,7 @@ function InstalledCard({
facts={
<>
{plugin.unverified && <UnverifiedBadge />}
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
<span>v{plugin.version}</span>
{adds.length > 0 && <span>{adds.join(", ")}</span>}
+12
View File
@@ -88,6 +88,18 @@ export function UnverifiedBadge() {
);
}
export function BetaBadge() {
const { t } = useTranslation();
return (
<span
className="shrink-0 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand"
title={t("plugins.manager.beta.badgeHint")}
>
{t("plugins.manager.beta.badge")}
</span>
);
}
/** Active installs when the registry has them, release downloads otherwise. */
export function InstallCountFact({
count,
+56
View File
@@ -11,6 +11,7 @@ import {
type ChangelogRelease,
} from "@termix-ssh/plugin-sdk/changelog";
import type {
PluginChannel,
PluginSummary,
PluginUploadPreview,
RegistryPluginEntry,
@@ -38,6 +39,11 @@ export interface PluginEntry {
installed: boolean;
version: string | null;
latestVersion: string | null;
/** Newest beta, when one is newer than stable. */
latestBeta: string | null;
channel: PluginChannel;
/** The installed copy is a beta. */
isBeta: boolean;
updateAvailable: boolean;
addedCapabilities: string[];
versions: RegistryPluginVersion[];
@@ -60,6 +66,26 @@ export interface PluginEntry {
export type PluginSource = "bundled" | "official" | "unverified";
export function isPrerelease(version: string | null | undefined): boolean {
return Boolean(
version && semver.valid(version) && semver.prerelease(version),
);
}
/** A beta newer than what is installed, worth offering with "Try beta". */
export function betaToTry(
plugin: Pick<PluginEntry, "installed" | "version" | "latestBeta" | "channel">,
): string | null {
const { latestBeta, version } = plugin;
if (!plugin.installed || plugin.channel === "beta" || !latestBeta) {
return null;
}
if (!version || !semver.valid(version) || !semver.valid(latestBeta)) {
return null;
}
return semver.gt(latestBeta, version) ? latestBeta : null;
}
/** Where an installed copy came from, as the issue report names it. */
export function pluginSource(summary: PluginSummary): PluginSource {
if (summary.tier === "unverified") return "unverified";
@@ -101,6 +127,9 @@ export function mergePlugins(
installed: true,
version: summary.version,
latestVersion: entry?.latestVersion ?? null,
latestBeta: entry?.latestBeta ?? null,
channel: summary.channel ?? entry?.channel ?? "stable",
isBeta: isPrerelease(summary.version),
updateAvailable: entry?.updateAvailable ?? false,
addedCapabilities: entry?.addedCapabilities ?? [],
versions: entry?.versions ?? [],
@@ -138,6 +167,9 @@ export function mergePlugins(
installed: false,
version: null,
latestVersion: entry.latestVersion,
latestBeta: entry.latestBeta ?? null,
channel: "stable",
isBeta: false,
updateAvailable: false,
addedCapabilities: [],
versions: entry.versions,
@@ -173,6 +205,9 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
installed: preview.replaces !== null,
version: preview.replaces,
latestVersion: preview.version,
latestBeta: null,
channel: "stable",
isBeta: isPrerelease(preview.replaces),
updateAvailable: false,
addedCapabilities: [],
versions: [],
@@ -358,6 +393,27 @@ ${state}`,
});
}
/**
* Beta feedback on the plugin's own repo, through the org beta form. Null when
* the repository is not on GitHub.
*/
export function betaFeedbackUrl(
plugin: Pick<PluginEntry, "id" | "repository" | "version" | "lastError">,
termixVersion?: string,
): string | null {
const repo = normalizeGitHubRepo(plugin.repository);
if (!repo) return null;
return buildIssueUrl(repo, {
template: "beta_feedback.yml",
fields: {
"termix-version": termixVersion,
"plugin-version": plugin.version,
environment: describeEnvironment(),
logs: plugin.lastError,
},
});
}
/** A new feature request on the plugin's own repo, or null when not on GitHub. */
export function requestFeatureUrl(
plugin: Pick<PluginEntry, "repository">,
+59 -1
View File
@@ -12,6 +12,7 @@ import {
installUploadedPlugin,
previewPluginState,
retryPlugin,
setAllPluginChannels,
setPluginOptions,
setPluginState,
uninstallPlugin,
@@ -19,6 +20,7 @@ import {
updatePlugin,
uploadPlugin,
type DeveloperModeState,
type PluginChannel,
type PluginSummary,
type RegistryListing,
} from "@/api/plugins-api";
@@ -484,7 +486,11 @@ export function usePluginsManager() {
const setOptions = useCallback(
(
plugin: PluginEntry,
options: { autoUpdate?: boolean; pinned?: boolean },
options: {
autoUpdate?: boolean;
pinned?: boolean;
channel?: PluginChannel;
},
) =>
run(plugin.id, async () => {
try {
@@ -496,6 +502,55 @@ export function usePluginsManager() {
[run, failToast],
);
/** Moves a plugin to betas and installs the newest one. */
const tryBeta = useCallback(
async (plugin: PluginEntry) => {
const version = plugin.latestBeta;
const ok = await confirm({
title: t("plugins.manager.beta.confirmTitle", { name: plugin.name }),
description: t("plugins.manager.beta.confirmBody"),
confirmLabel: version
? t("plugins.manager.beta.confirmInstall", { version })
: t("plugins.manager.beta.confirmSwitch"),
destructive: false,
});
if (!ok) return;
await setOptions(plugin, { channel: "beta", pinned: false });
if (version && version !== plugin.version) {
await requestUpdate({ ...plugin, pinnedVersion: null }, version);
}
},
[confirm, t, setOptions, requestUpdate],
);
/** Back to stable. An installed beta stays until stable passes it. */
const leaveBeta = useCallback(
async (plugin: PluginEntry) => {
await setOptions(plugin, { channel: "stable" });
if (plugin.isBeta) {
toast.info(
t("plugins.manager.beta.leftWhileOnBeta", {
name: plugin.name,
version: plugin.version,
}),
);
}
},
[setOptions, t],
);
const setAllChannels = useCallback(
(channel: PluginChannel) =>
run("*", async () => {
try {
await setAllPluginChannels(channel);
} catch (error) {
failToast(error, "plugins.manager.errors.options", "");
}
}),
[run, failToast],
);
const updateAll = useCallback(
() =>
run("*", async () => {
@@ -545,6 +600,9 @@ export function usePluginsManager() {
uninstall,
removeData,
setOptions,
tryBeta,
leaveBeta,
setAllChannels,
updateAll,
};
}
+2
View File
@@ -29,6 +29,7 @@ import {
useFeatureSettings,
} from "./FeatureSettingsSections";
import { KeybindingsSettings } from "./KeybindingsSettings";
import { UpdatesSettings } from "./UpdatesSettings";
import { SettingsEmbedContext } from "./settings-embed";
import { useSettingsPageFilter } from "./page-filter";
import {
@@ -376,6 +377,7 @@ export function SettingsTab({
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && isAdmin && <UpdatesSettings />}
{pluginFeature && (
<>
{pluginFeature.user && (
+320
View File
@@ -0,0 +1,320 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Copy, ExternalLink, FlaskConical, Puzzle, Tag } from "lucide-react";
import { Button } from "@/components/button";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import {
getChannelReleases,
getUpdateChannel,
setUpdateChannel,
type ReleaseInfo,
type UpdateChannelState,
} from "@/api/system-status-api";
import {
getPlugins,
setAllPluginChannels,
setPluginOptions,
type PluginSummary,
} from "@/api/plugins-api";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
const BETA_DOCS_URL =
"https://docs.termix.site/install/server/docker#beta-builds";
function onBeta(plugin: PluginSummary): boolean {
return plugin.channel === "beta" || isPrerelease(plugin.version);
}
function ReleaseRow({
label,
release,
}: {
label: string;
release: ReleaseInfo | null;
}) {
const { t } = useTranslation();
return (
<SettingRow
label={label}
description={
release
? new Date(release.publishedAt).toLocaleDateString()
: t("settings.updates.noRelease")
}
>
{release && (
<a
href={release.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs font-medium text-accent-brand hover:underline"
>
v{release.version}
<ExternalLink className="size-3" />
</a>
)}
</SettingRow>
);
}
export function UpdatesSettings() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannelState | null>(null);
const [releases, setReleases] = useState<{
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
} | null>(null);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState(false);
const version = import.meta.env.VITE_APP_VERSION || undefined;
const loadPlugins = useCallback(async () => {
try {
setPlugins(await getPlugins());
} catch {
setPlugins([]);
}
}, []);
useEffect(() => {
getUpdateChannel()
.then(setChannel)
.catch(() => setChannel(null));
getChannelReleases()
.then(setReleases)
.catch(() => setReleases({ stable: null, beta: null }));
void loadPlugins();
}, [loadPlugins]);
const betaPlugins = plugins.filter(onBeta);
const runningBetas = plugins
.filter((plugin) => isPrerelease(plugin.version))
.map((plugin) => ({ id: plugin.id, version: plugin.version }));
const joined = channel?.channel === "beta";
const changeChannel = async (next: boolean) => {
setBusy(true);
try {
setChannel(await setUpdateChannel(next ? "beta" : "stable"));
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const changePlugins = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
await loadPlugins();
setBusy(false);
}
};
const copyImage = async () => {
try {
await navigator.clipboard.writeText(DOCKER_BETA_IMAGE);
toast.success(t("settings.updates.copied"));
} catch {
toast.error(t("settings.updates.copyFailed"));
}
};
return (
<div className="flex flex-col gap-3">
<SectionCard
title={t("settings.updates.betaProgram")}
icon={<FlaskConical className="size-3.5" />}
rowId="updates-beta"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.betaIntro")}
</p>
<SettingRow
label={t("settings.updates.termixBetas")}
description={
channel?.runningBeta
? t("settings.updates.runningBetaBuild")
: t("settings.updates.termixBetasHint")
}
>
<FakeSwitch
checked={joined}
disabled={busy || !channel || channel.runningBeta}
onChange={(next) => void changeChannel(next)}
/>
</SettingRow>
{joined && !channel?.runningBeta && (
<div className="flex flex-col gap-2 py-3 text-xs">
<span className="font-medium">
{t("settings.updates.howToInstall")}
</span>
{isElectron() ? (
<span className="text-muted-foreground">
{t("settings.updates.desktopInstall")}{" "}
{releases?.beta && (
<a
href={releases.beta.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
>
{t("settings.updates.downloadBeta", {
version: releases.beta.version,
})}
</a>
)}
</span>
) : (
<>
<span className="text-muted-foreground">
{t("settings.updates.dockerInstall")}
</span>
<div className="flex items-center gap-2">
<code className="flex-1 truncate border border-border bg-muted/40 px-2 py-1.5 font-mono text-[11px]">
{DOCKER_BETA_IMAGE}
</code>
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
onClick={() => void copyImage()}
>
<Copy className="size-3.5" />
{t("settings.updates.copy")}
</Button>
</div>
</>
)}
<a
href={BETA_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent-brand hover:underline"
>
{t("settings.updates.learnMore")}
<ExternalLink className="size-3" />
</a>
</div>
)}
<SettingRow
label={t("settings.updates.feedback")}
description={t("settings.updates.feedbackHint")}
>
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={reportBetaFeedbackUrl(version, runningBetas)}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.updates.sendFeedback")}
</a>
</Button>
</SettingRow>
</SectionCard>
<SectionCard
title={t("settings.updates.releases")}
icon={<Tag className="size-3.5" />}
rowId="updates-releases"
>
<SettingRow label={t("settings.updates.thisVersion")}>
<span className="text-xs font-medium">
{version ? `v${version}` : "-"}
</span>
</SettingRow>
<ReleaseRow
label={t("settings.updates.newestStable")}
release={releases?.stable ?? null}
/>
<ReleaseRow
label={t("settings.updates.newestBeta")}
release={releases?.beta ?? null}
/>
</SectionCard>
<SectionCard
title={t("settings.updates.pluginBetas")}
icon={<Puzzle className="size-3.5" />}
rowId="updates-plugins"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.pluginBetasIntro")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_PLUGINS_EVENT))
}
>
{t("settings.updates.openPlugins")}
</button>
</p>
{betaPlugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
description={
plugin.channel === "beta"
? `v${plugin.version}`
: t("settings.updates.waitingForStable", {
version: plugin.version,
})
}
>
{plugin.channel === "beta" && (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() =>
setPluginOptions(plugin.id, { channel: "stable" }),
)
}
>
{t("settings.updates.backToStable")}
</Button>
)}
</SettingRow>
))}
{plugins.length > 0 && (
<div className="flex flex-wrap gap-2 py-3">
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() => setAllPluginChannels("beta"))
}
>
{t("settings.updates.allPluginsBeta")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy || betaPlugins.length === 0}
onClick={() =>
void changePlugins(() => setAllPluginChannels("stable"))
}
>
{t("settings.updates.allPluginsStable")}
</Button>
</div>
)}
</SectionCard>
</div>
);
}
+8
View File
@@ -1,6 +1,7 @@
import {
Database,
FileText,
FlaskConical,
Globe,
KeyRound,
Keyboard,
@@ -116,6 +117,13 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
admin: true,
adminSection: "general",
}),
core({
id: "updates",
band: "instance",
labelKey: "settings.updates.title",
icon: FlaskConical,
admin: true,
}),
core({
id: "admin-users",
band: "instance",
+37 -4
View File
@@ -4,10 +4,6 @@
export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"account": [
"newUi.sidebar.userProfile.authMethodLabel",
"newUi.sidebar.userProfile.betaProgramDescription",
"newUi.sidebar.userProfile.betaProgramFeedback",
"newUi.sidebar.userProfile.betaProgramLearnMore",
"newUi.sidebar.userProfile.betaProgramTitle",
"newUi.sidebar.userProfile.cliDescription",
"newUi.sidebar.userProfile.cliLearnMore",
"newUi.sidebar.userProfile.cliTitle",
@@ -19,9 +15,15 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"newUi.sidebar.userProfile.linkedProfileDescription",
"newUi.sidebar.userProfile.linkedProfileTitle",
"newUi.sidebar.userProfile.manageSync",
"newUi.sidebar.userProfile.onBetaDescription",
"newUi.sidebar.userProfile.onBetaTitle",
"newUi.sidebar.userProfile.openUpdates",
"newUi.sidebar.userProfile.roleLabel",
"newUi.sidebar.userProfile.sectionAccount",
"newUi.sidebar.userProfile.sendBetaFeedback",
"newUi.sidebar.userProfile.setUpSync",
"newUi.sidebar.userProfile.tryBetaDescription",
"newUi.sidebar.userProfile.tryBetaTitle",
"newUi.sidebar.userProfile.twoFaLabel",
"newUi.sidebar.userProfile.twoFaOff",
"newUi.sidebar.userProfile.twoFaOn",
@@ -431,4 +433,35 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"newUi.sidebar.userProfile.sectionSecurity",
"newUi.sidebar.userProfile.updatePassword",
],
"updates": [
"settings.updates.allPluginsBeta",
"settings.updates.allPluginsStable",
"settings.updates.backToStable",
"settings.updates.betaIntro",
"settings.updates.betaProgram",
"settings.updates.copied",
"settings.updates.copy",
"settings.updates.copyFailed",
"settings.updates.desktopInstall",
"settings.updates.dockerInstall",
"settings.updates.downloadBeta",
"settings.updates.feedback",
"settings.updates.feedbackHint",
"settings.updates.howToInstall",
"settings.updates.learnMore",
"settings.updates.newestBeta",
"settings.updates.newestStable",
"settings.updates.noRelease",
"settings.updates.openPlugins",
"settings.updates.pluginBetas",
"settings.updates.pluginBetasIntro",
"settings.updates.releases",
"settings.updates.runningBetaBuild",
"settings.updates.saveFailed",
"settings.updates.sendFeedback",
"settings.updates.termixBetas",
"settings.updates.termixBetasHint",
"settings.updates.thisVersion",
"settings.updates.waitingForStable",
],
};
+13 -1
View File
@@ -13,6 +13,7 @@ import {
import {
Bug,
Clock,
FlaskConical,
Folder,
Globe,
KeyRound,
@@ -43,7 +44,8 @@ import {
type PaletteItemDef,
} from "./palette-registry";
import { activityTarget } from "@/lib/activity-types";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
@@ -234,6 +236,16 @@ export function CommandPalette({
icon: Bug,
url: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined),
},
...(isPrerelease(import.meta.env.VITE_APP_VERSION)
? [
{
id: "link-beta-feedback",
label: t("newUi.sidebar.userProfile.sendBetaFeedback"),
icon: FlaskConical,
url: reportBetaFeedbackUrl(import.meta.env.VITE_APP_VERSION),
},
]
: []),
].filter((link) => matches(link.label));
const rowClass = "group gap-2 rounded-none";
+33 -25
View File
@@ -68,7 +68,8 @@ import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { Select2 } from "@/components/select2";
import { LANGUAGES, THEMES } from "@/settings/appearance/appearance-controls";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import {
FeatureSettingsSection,
featureSectionId,
@@ -464,6 +465,7 @@ export function UserProfilePanel({
const [accountUsername, setAccountUsername] = useState(username ?? "");
const [accountTotpEnabled, setAccountTotpEnabled] = useState(false);
const [userRole, setUserRole] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [authMethod, setAuthMethod] = useState("");
const [version, setVersion] = useState("");
const [versionStatus, setVersionStatus] = useState<
@@ -586,6 +588,7 @@ export function UserProfilePanel({
// A linked desktop is the account it is signed in to on the server.
setAccountUsername(linked?.username || info.username);
setAccountTotpEnabled(info.totp_enabled ?? false);
setIsAdmin(Boolean(linked ? linked.isAdmin : info.is_admin));
setUserRole(
(linked ? linked.isAdmin : info.is_admin)
? t("newUi.sidebar.userProfile.roleAdministrator")
@@ -1294,37 +1297,42 @@ export function UserProfilePanel({
</span>
</div>
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("newUi.sidebar.userProfile.betaProgramTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.betaProgramDescription")}{" "}
{(isPrerelease(version) || isAdmin) && (
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{isPrerelease(version)
? t("newUi.sidebar.userProfile.onBetaTitle")
: t("newUi.sidebar.userProfile.tryBetaTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{isPrerelease(version)
? t("newUi.sidebar.userProfile.onBetaDescription")
: t("newUi.sidebar.userProfile.tryBetaDescription")}
</span>
</div>
{isPrerelease(version) ? (
<a
href={reportCoreIssueUrl(
version || undefined,
"beta_feedback.yml",
)}
href={reportBetaFeedbackUrl(version)}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.betaProgramFeedback")}
{t("newUi.sidebar.userProfile.sendBetaFeedback")}
</a>
</span>
) : (
<button
type="button"
onClick={() => onOpenSettingsPage?.("updates")}
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.openUpdates")}
</button>
)}
</div>
<a
href="https://docs.termix.site/install/server/docker#beta-builds"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.betaProgramLearnMore")}
</a>
</div>
</div>
)}
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
+24 -2
View File
@@ -3,6 +3,7 @@ import {
buildIssueUrl,
describeEnvironment,
normalizeGitHubRepo,
reportBetaFeedbackUrl,
reportCoreIssueUrl,
} from "@/lib/issue-url";
@@ -87,10 +88,31 @@ describe("describeEnvironment", () => {
describe("reportCoreIssueUrl", () => {
it("points at the core repo with the version filled in", () => {
const url = new URL(reportCoreIssueUrl("26.10.0", "beta_feedback.yml"));
const url = new URL(reportCoreIssueUrl("26.10.0"));
expect(url.pathname).toBe("/Termix-SSH/Termix/issues/new");
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("template")).toBe("bug_report.yml");
expect(url.searchParams.get("termix-version")).toBe("26.10.0");
expect(url.searchParams.get("environment")).toBeTruthy();
});
});
describe("reportBetaFeedbackUrl", () => {
it("uses the beta form and lists plugins on a beta", () => {
const url = new URL(
reportBetaFeedbackUrl("26.11.0-beta.2", [
{ id: "docker", version: "1.2.0-beta.1" },
{ id: "tunnels", version: "1.1.0-beta.3" },
]),
);
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("termix-version")).toBe("26.11.0-beta.2");
expect(url.searchParams.get("beta-plugins")).toBe(
"docker 1.2.0-beta.1\ntunnels 1.1.0-beta.3",
);
});
it("leaves the plugin list out when none are on a beta", () => {
const url = new URL(reportBetaFeedbackUrl("26.11.0-beta.2"));
expect(url.searchParams.has("beta-plugins")).toBe(false);
});
});
+50
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { PluginSummary, RegistryPluginEntry } from "@/api/plugins-api";
import {
betaFeedbackUrl,
betaToTry,
isPrerelease,
categoriesOf,
describeContributions,
formatBytes,
@@ -416,3 +419,50 @@ describe("youtubeEmbedUrl", () => {
}
});
});
describe("betas", () => {
it("knows a prerelease", () => {
expect(isPrerelease("1.2.0-beta.1")).toBe(true);
expect(isPrerelease("1.2.0")).toBe(false);
expect(isPrerelease(null)).toBe(false);
});
it("offers a beta only when newer than what is installed on stable", () => {
const base = {
installed: true,
version: "1.1.0",
latestBeta: "1.2.0-beta.1",
channel: "stable" as const,
};
expect(betaToTry(base)).toBe("1.2.0-beta.1");
expect(betaToTry({ ...base, channel: "beta" })).toBeNull();
expect(betaToTry({ ...base, version: "1.2.0-beta.1" })).toBeNull();
expect(betaToTry({ ...base, installed: false })).toBeNull();
expect(betaToTry({ ...base, latestBeta: null })).toBeNull();
});
it("sends beta feedback to the plugin's repo", () => {
const url = new URL(
betaFeedbackUrl(
{
id: "docker",
repository: "https://github.com/Termix-SSH/Plugin-Docker",
version: "1.2.0-beta.1",
lastError: null,
},
"26.11.0",
)!,
);
expect(url.pathname).toBe("/Termix-SSH/Plugin-Docker/issues/new");
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("plugin-version")).toBe("1.2.0-beta.1");
expect(
betaFeedbackUrl({
id: "x",
repository: "https://gitlab.com/a/b",
version: "1.0.0",
lastError: null,
}),
).toBeNull();
});
});