mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev
This commit is contained in:
1 parent
49fc241e06
commit
ec80a8ef8d
24 files changed
+1848
-44
No files matched your search
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* IPC between the backend and scripts/dev.mjs, which forks it with
|
||||
* TERMIX_DEV_RELOAD=true. The runner asks for a plugin to be loaded again
|
||||
* after it rebuilt and copied it into the bundled folder.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
interface ReloadRequest {
|
||||
type: "plugin-reload";
|
||||
id: string;
|
||||
}
|
||||
|
||||
type Send = (message: unknown) => void;
|
||||
type Reload = (id: string) => Promise<{ version: string; state: string }>;
|
||||
|
||||
function isReloadRequest(msg: unknown): msg is ReloadRequest {
|
||||
return (
|
||||
!!msg &&
|
||||
typeof msg === "object" &&
|
||||
(msg as { type?: unknown }).type === "plugin-reload" &&
|
||||
typeof (msg as { id?: unknown }).id === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function isDevReloadEnabled(): boolean {
|
||||
return (
|
||||
process.env.TERMIX_DEV_RELOAD === "true" &&
|
||||
typeof process.send === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleDevMessage(
|
||||
msg: unknown,
|
||||
send: Send,
|
||||
reload: Reload,
|
||||
): Promise<void> {
|
||||
if (!isReloadRequest(msg)) return;
|
||||
try {
|
||||
const result = await reload(msg.id);
|
||||
pluginLogger.info(`Reloaded ${msg.id}@${result.version}`, {
|
||||
operation: "plugin_dev_reload",
|
||||
});
|
||||
send({ type: "plugin-reloaded", id: msg.id, state: result.state });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pluginLogger.error(`Could not reload ${msg.id}`, error, {
|
||||
operation: "plugin_dev_reload",
|
||||
});
|
||||
send({ type: "plugin-reload-failed", id: msg.id, error: message });
|
||||
}
|
||||
}
|
||||
|
||||
export function listenForDevReloads(): void {
|
||||
if (!isDevReloadEnabled()) return;
|
||||
process.on("message", (msg: unknown) => {
|
||||
void handleDevMessage(
|
||||
msg,
|
||||
(reply) => process.send?.(reply),
|
||||
async (id) => {
|
||||
const { reloadBundledPlugin } = await import("./manage.js");
|
||||
return reloadBundledPlugin(id);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1290,6 +1290,44 @@ export function installUpload(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the copy in the bundled folder again after it was rebuilt, for the
|
||||
* dev runner. Running dependents stop first and start again after.
|
||||
*/
|
||||
export function reloadBundledPlugin(
|
||||
pluginId: string,
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
return serialized(async () => {
|
||||
const { loader } = getPluginRuntime();
|
||||
const previous = loader.get(pluginId);
|
||||
if (previous && previous.source !== "bundled") {
|
||||
throw new PluginManageError(
|
||||
`${pluginId} is not loaded from the bundled folder`,
|
||||
409,
|
||||
"NOT_BUNDLED",
|
||||
);
|
||||
}
|
||||
const dependents = previous
|
||||
? dependentChain(pluginId, await enabledIds()).filter(
|
||||
(id) => loader.get(id)?.state === "active",
|
||||
)
|
||||
: [];
|
||||
for (const id of dependents) await deactivatePlugin(id);
|
||||
await unloadPlugin(pluginId);
|
||||
|
||||
const plugin = await loader.loadBundled(pluginId);
|
||||
await registerLoadedPlugins([plugin]);
|
||||
|
||||
let state = "stopped";
|
||||
if ((await enabledIds()).has(pluginId)) {
|
||||
const { missing } = dependencyChain(pluginId);
|
||||
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
|
||||
}
|
||||
for (const id of [...dependents].reverse()) await startAndRecord(id);
|
||||
return { id: pluginId, version: plugin.manifest.version, state };
|
||||
});
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetUploads(): void {
|
||||
uploads.clear();
|
||||
|
||||
@@ -283,7 +283,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
});
|
||||
|
||||
// Log output can be filtered by the configured level or split across chunks.
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
if (
|
||||
process.env.ELECTRON_EMBEDDED === "true" ||
|
||||
process.env.TERMIX_DEV_RELOAD === "true"
|
||||
) {
|
||||
process.send?.({ type: "backend-ready" });
|
||||
}
|
||||
|
||||
@@ -328,6 +331,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.TERMIX_DEV_RELOAD === "true") {
|
||||
const { listenForDevReloads } = await import("./plugins/dev-reload.js");
|
||||
listenForDevReloads();
|
||||
}
|
||||
|
||||
// A single bad request must not take the server down. Exit only on errors
|
||||
// that leave the process genuinely unusable; log and keep serving
|
||||
// otherwise, since these are almost always scoped to one connection.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: { info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { handleDevMessage } = await import("../../plugins/dev-reload.js");
|
||||
|
||||
describe("dev reload messages", () => {
|
||||
it("reloads the plugin and answers with its state", async () => {
|
||||
const send = vi.fn();
|
||||
const reload = vi.fn(async () => ({ version: "1.0.0", state: "active" }));
|
||||
await handleDevMessage(
|
||||
{ type: "plugin-reload", id: "snippets" },
|
||||
send,
|
||||
reload,
|
||||
);
|
||||
expect(reload).toHaveBeenCalledWith("snippets");
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
type: "plugin-reloaded",
|
||||
id: "snippets",
|
||||
state: "active",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers with the error when the reload fails", async () => {
|
||||
const send = vi.fn();
|
||||
await handleDevMessage(
|
||||
{ type: "plugin-reload", id: "snippets" },
|
||||
send,
|
||||
async () => {
|
||||
throw new Error("bad manifest");
|
||||
},
|
||||
);
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
type: "plugin-reload-failed",
|
||||
id: "snippets",
|
||||
error: "bad manifest",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores other messages", async () => {
|
||||
const send = vi.fn();
|
||||
const reload = vi.fn();
|
||||
await handleDevMessage({ type: "shutdown" }, send, reload);
|
||||
await handleDevMessage({ type: "plugin-reload" }, send, reload);
|
||||
await handleDevMessage(null, send, reload);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -445,6 +445,46 @@ describe("dependencies", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloading a bundled plugin for the dev runner", () => {
|
||||
it("loads the rebuilt copy and restarts what depends on it", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
const loader = await boot();
|
||||
createFixturePlugin({
|
||||
id: "base",
|
||||
root: bundled,
|
||||
manifestOverrides: { version: "1.1.0" },
|
||||
});
|
||||
|
||||
const result = await manage.reloadBundledPlugin("base");
|
||||
expect(result).toEqual({ id: "base", version: "1.1.0", state: "active" });
|
||||
expect(loader.get("base")?.manifest.version).toBe("1.1.0");
|
||||
expect(loader.get("addon")?.state).toBe("active");
|
||||
expect(db.rows.get("base")?.version).toBe("1.1.0");
|
||||
});
|
||||
|
||||
it("loads and starts a plugin added since boot", async () => {
|
||||
const loader = await boot();
|
||||
createFixturePlugin({ id: "fresh", root: bundled });
|
||||
const result = await manage.reloadBundledPlugin("fresh");
|
||||
expect(result.state).toBe("active");
|
||||
expect(loader.get("fresh")?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("keeps a disabled plugin stopped", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
const loader = await boot();
|
||||
await manage.setPluginState("base", false);
|
||||
const result = await manage.reloadBundledPlugin("base");
|
||||
expect(result.state).toBe("stopped");
|
||||
expect(loader.get("base")?.state).toBe("loaded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consent", () => {
|
||||
it("refuses an install without the consented list, before any download", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
|
||||
@@ -398,6 +398,11 @@ function onPluginsChanged() {
|
||||
void syncPlugins();
|
||||
}
|
||||
|
||||
// Sent by scripts/dev.mjs after it rebuilt a plugin or restarted the backend.
|
||||
import.meta.hot?.on("termix:plugins-reloaded", () => {
|
||||
if (started) void syncPlugins();
|
||||
});
|
||||
|
||||
function onFocus() {
|
||||
const now = Date.now();
|
||||
if (now - lastFocusSync < FOCUS_SYNC_INTERVAL_MS) return;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { useHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
|
||||
import { RailBadge } from "@/sidebar/RailBadge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
const PRIMARY_COUNT = 4;
|
||||
|
||||
@@ -183,14 +184,16 @@ export function MobileBar({
|
||||
onOpenSettings();
|
||||
}}
|
||||
/>
|
||||
<SheetRow
|
||||
icon={LogOut}
|
||||
label={t("common.logout")}
|
||||
onClick={() => {
|
||||
close();
|
||||
onLogout();
|
||||
}}
|
||||
/>
|
||||
{!isElectron() && (
|
||||
<SheetRow
|
||||
icon={LogOut}
|
||||
label={t("common.logout")}
|
||||
onClick={() => {
|
||||
close();
|
||||
onLogout();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+20
-14
@@ -25,6 +25,7 @@ import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
|
||||
import { RailBadge } from "./RailBadge";
|
||||
import { rem } from "@/lib/rem";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
/** Core rail views; plugins add their own ids at runtime. */
|
||||
export type CoreRailView =
|
||||
@@ -437,20 +438,25 @@ export function AppRail({
|
||||
</>
|
||||
)}
|
||||
{footerItems.map(renderItem)}
|
||||
{footerItems.length > 0 && <Rule expanded={railExpanded} />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLogout({ manual: true })}
|
||||
style={btnStyle}
|
||||
title={t("common.logout")}
|
||||
aria-label={t("common.logout")}
|
||||
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
||||
>
|
||||
<RailIcon>
|
||||
<LogOut size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
|
||||
</button>
|
||||
{footerItems.length > 0 && !isElectron() && (
|
||||
<Rule expanded={railExpanded} />
|
||||
)}
|
||||
{/* The desktop signs in to its own local profile by itself. */}
|
||||
{!isElectron() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLogout({ manual: true })}
|
||||
style={btnStyle}
|
||||
title={t("common.logout")}
|
||||
aria-label={t("common.logout")}
|
||||
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
||||
>
|
||||
<RailIcon>
|
||||
<LogOut size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border">
|
||||
|
||||
@@ -24,6 +24,7 @@ afterEach(() => {
|
||||
cleanup();
|
||||
resetRegisteredRailItems();
|
||||
localStorage.clear();
|
||||
delete (window as { IS_ELECTRON?: boolean }).IS_ELECTRON;
|
||||
});
|
||||
|
||||
function renderBar(onRailClick = vi.fn()) {
|
||||
@@ -77,6 +78,17 @@ describe("MobileBar", () => {
|
||||
expect(screen.getByText("nav.group.objects")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves logout out of More on the desktop", () => {
|
||||
renderBar();
|
||||
fireEvent.click(screen.getByLabelText("common.more"));
|
||||
expect(screen.getByText("common.logout")).toBeTruthy();
|
||||
cleanup();
|
||||
(window as { IS_ELECTRON?: boolean }).IS_ELECTRON = true;
|
||||
renderBar();
|
||||
fireEvent.click(screen.getByLabelText("common.more"));
|
||||
expect(screen.queryByText("common.logout")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves out hidden items", () => {
|
||||
localStorage.setItem("hiddenRailTabs", JSON.stringify(["hosts"]));
|
||||
renderBar();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Bell, Boxes } from "lucide-react";
|
||||
import { AppRail } from "../../sidebar/AppRail";
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/hooks/use-permissions", () => ({
|
||||
afterEach(() => {
|
||||
resetRegisteredRailItems();
|
||||
localStorage.clear();
|
||||
delete (window as { IS_ELECTRON?: boolean }).IS_ELECTRON;
|
||||
});
|
||||
|
||||
function renderRail(onRailClick = vi.fn()) {
|
||||
@@ -44,6 +45,15 @@ function renderRail(onRailClick = vi.fn()) {
|
||||
}
|
||||
|
||||
describe("AppRail", () => {
|
||||
it("shows logout in the browser but not on the desktop", () => {
|
||||
renderRail();
|
||||
expect(screen.getByLabelText("common.logout")).toBeTruthy();
|
||||
cleanup();
|
||||
(window as { IS_ELECTRON?: boolean }).IS_ELECTRON = true;
|
||||
renderRail();
|
||||
expect(screen.queryByLabelText("common.logout")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts a footer item above the profile with its badge", async () => {
|
||||
registerRailItem({
|
||||
id: "inbox",
|
||||
|
||||
Reference in new issue
Block a user