feat: move the classic layout into its own bundled plugin

This commit is contained in:
LukeGus committed 2026-10-18 17:25:59 -05:00
1 parent 86161d66e2
commit d049588f7f
234 files changed
+3066 -76205

No files matched your search

+57
View File
@@ -2281,6 +2281,63 @@ router.post(
},
);
/**
* @openapi
* /plugins/{id}/restore-bundled:
* post:
* summary: Restore the bundled copy of a plugin
* description: >
* Drops any update downloaded over a plugin that ships with Termix and
* loads the copy from the image again, so a broken release can be rolled
* back from the layout repair view. The shipped version is pinned so
* auto update does not bring the release back. Dependents restart.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The restored version and the plugin's state.
* 400:
* description: Invalid plugin id.
* 403:
* description: The caller lacks admin.plugins.manage.
* 409:
* description: The plugin has no bundled copy, or a linked server manages it.
*/
router.post(
"/:id/restore-bundled",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
if (!ID_PATTERN.test(pluginId)) {
res.status(400).json({ error: "Invalid plugin id" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { restoreBundledPlugin } = await import("../../plugins/manage.js");
const result = await restoreBundledPlugin(pluginId);
await auditPluginAction(req, "restore_bundled_plugin", pluginId, {
version: result.version,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to restore the bundled copy",
"plugin_restore_bundled",
);
}
},
);
/**
* @openapi
* /plugins/{id}:
+76 -23
View File
@@ -1644,41 +1644,94 @@ export function installUpload(
});
}
/**
* Loads the copy in the bundled folder again. Running dependents stop first
* and start again after. `prepare` runs once the plugin is stopped, for work
* that needs its files out of use.
*/
async function reloadBundledUnlocked(
pluginId: string,
prepare?: (previous: LoadedPlugin | undefined) => Promise<void>,
): Promise<{ id: string; version: string; state: string }> {
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled" && !prepare) {
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);
if (prepare) await prepare(previous);
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 };
}
/**
* Loads the copy in the bundled folder again after it was rebuilt, for the
* dev runner. Running dependents stop first and start again after.
* dev runner.
*/
export function reloadBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(() => reloadBundledUnlocked(pluginId));
}
/**
* Puts a bundled plugin back to the copy that shipped with this install,
* dropping any update downloaded over it. The shipped version is pinned so
* auto update does not bring a broken release straight back; unpin it in the
* Plugins tab to follow updates again.
*/
export function restoreBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(async () => {
if (!ID_PATTERN.test(pluginId)) {
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
}
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled") {
if (!loader.bundledIds().has(pluginId) || !isBundledOnDisk(pluginId)) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
"This plugin has no bundled copy",
409,
"NOT_BUNDLED",
"NO_BUNDLED_COPY",
);
}
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 };
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const result = await reloadBundledUnlocked(pluginId, async (previous) => {
await removeArtifactFiles(pluginId, previous?.artifact);
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
recursive: true,
force: true,
});
});
await repository.update(pluginId, {
autoUpdate: false,
pinnedVersion: result.version,
});
await applyPin(pluginId, result.version);
pluginLogger.info(`Restored the bundled copy of ${pluginId}`, {
operation: "plugin_restore_bundled",
});
return result;
});
}
@@ -169,6 +169,7 @@ const manageMock = vi.hoisted(() => ({
updatePlugin: vi.fn(),
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
restoreBundledPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
@@ -1141,6 +1142,35 @@ describe("plugins route", () => {
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
});
it("restores the bundled copy", async () => {
manageMock.restoreBundledPlugin.mockResolvedValue({
id: "docker",
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "docker",
version: "1.0.0",
state: "active",
});
expect(manageMock.restoreBundledPlugin).toHaveBeenCalledWith("docker");
});
it("answers 409 when a plugin has no bundled copy", async () => {
manageMock.restoreBundledPlugin.mockRejectedValue(
new PluginManageError(
"This plugin has no bundled copy",
409,
"NO_BUNDLED_COPY",
),
);
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("NO_BUNDLED_COPY");
});
it("refuses changes on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await post("/plugins/docker/install");
+48
View File
@@ -661,6 +661,54 @@ describe("reloading a bundled plugin for the dev runner", () => {
});
});
describe("restoring the bundled copy", () => {
it("drops a downloaded update, loads the shipped copy and pins it", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
// What a signed update leaves behind next to the shipped copy.
const artifact = path.join(data, "plugins", "docker.tmxplug");
fs.mkdirSync(path.dirname(artifact), { recursive: true });
fs.writeFileSync(artifact, "update");
fs.writeFileSync(`${artifact}.sig`, "sig");
const result = await manage.restoreBundledPlugin("docker");
expect(result).toEqual({ id: "docker", version: "1.0.0", state: "active" });
expect(loader.get("docker")?.manifest.version).toBe("1.0.0");
expect(fs.existsSync(artifact)).toBe(false);
expect(fs.existsSync(`${artifact}.sig`)).toBe(false);
expect(db.rows.get("docker")).toMatchObject({
pinnedVersion: "1.0.0",
autoUpdate: false,
});
});
it("keeps a plugin that is turned off stopped", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
await manage.setPluginState("docker", false);
const result = await manage.restoreBundledPlugin("docker");
expect(result.state).toBe("stopped");
expect(loader.get("docker")?.state).toBe("loaded");
});
it("refuses a plugin that has no bundled copy", async () => {
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
await boot();
await expect(manage.restoreBundledPlugin("notes")).rejects.toMatchObject({
code: "NO_BUNDLED_COPY",
status: 409,
});
});
it("refuses a bad id", async () => {
await expect(manage.restoreBundledPlugin("../x")).rejects.toMatchObject({
code: "INVALID_ID",
status: 400,
});
});
});
describe("consent", () => {
it("refuses an install without the consented list, before any download", async () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];