mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-06 02:25:24 +00:00
feat: new bundled plugins start on only if recommended, fetch missing shipped ones
This commit is contained in:
1 parent
9613b787a7
commit
c7aa1c64b5
9 files changed
+240
-24
No files matched your search
@@ -74,7 +74,7 @@ async function main() {
|
||||
const staged = [...pinned, ...extra];
|
||||
fs.writeFileSync(
|
||||
path.join(destination, "bundled-index.json"),
|
||||
`${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)}
|
||||
`${JSON.stringify(buildBundledIndex(plugins, staged, profile), null, 2)}
|
||||
`,
|
||||
);
|
||||
|
||||
|
||||
@@ -221,7 +221,34 @@ describe("buildBundledIndex", () => {
|
||||
];
|
||||
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
|
||||
version: 1,
|
||||
profile: "standard",
|
||||
plugins: { a: { onboarding: { recommended: true, consent: false } } },
|
||||
catalog: {
|
||||
a: { tier: "full" },
|
||||
b: { tier: "full" },
|
||||
c: { tier: "full" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lists every pin with its tier and version", () => {
|
||||
const plugins = [
|
||||
{
|
||||
id: "a",
|
||||
tier: "standard",
|
||||
url: "https://example.com/v1.2.0/a-1.2.0.tmxplug",
|
||||
onboarding: null,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
tier: "minimal",
|
||||
path: "x/b-2.0.0-beta.1.tmxplug",
|
||||
onboarding: null,
|
||||
},
|
||||
];
|
||||
expect(buildBundledIndex(plugins, [], "minimal").catalog).toEqual({
|
||||
a: { tier: "standard", version: "1.2.0" },
|
||||
b: { tier: "minimal", version: "2.0.0-beta.1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -520,7 +547,7 @@ describe("default look", () => {
|
||||
],
|
||||
["look", "plain"],
|
||||
),
|
||||
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||
).toMatchObject({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||
|
||||
const index = {
|
||||
plugins: [
|
||||
|
||||
@@ -236,12 +236,22 @@ async function extractArtifact(buffer, outDir, id) {
|
||||
|
||||
/**
|
||||
* What dist/plugins/bundled-index.json holds: the onboarding defaults of each
|
||||
* staged plugin, read by the backend when it seeds a fresh install.
|
||||
* staged plugin, read by the backend when it seeds a fresh install, plus the
|
||||
* profile and every pin it ships, so an existing install can fetch a shipped
|
||||
* plugin it finds missing.
|
||||
*/
|
||||
function buildBundledIndex(plugins, stagedIds) {
|
||||
function buildBundledIndex(plugins, stagedIds, profile = DEFAULT_PROFILE) {
|
||||
const staged = new Set(stagedIds);
|
||||
const out = {};
|
||||
const catalog = {};
|
||||
for (const plugin of plugins) {
|
||||
const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tmxplug$/.exec(
|
||||
plugin.url ?? plugin.path ?? "",
|
||||
)?.[1];
|
||||
catalog[plugin.id] = {
|
||||
tier: plugin.tier ?? "full",
|
||||
...(version ? { version } : {}),
|
||||
};
|
||||
if (!staged.has(plugin.id)) continue;
|
||||
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
||||
out[plugin.id] = {
|
||||
@@ -249,7 +259,7 @@ function buildBundledIndex(plugins, stagedIds) {
|
||||
...(plugin.defaultLook ? { defaultLook: true } : {}),
|
||||
};
|
||||
}
|
||||
return { version: 1, plugins: out };
|
||||
return { version: 1, profile, plugins: out, catalog };
|
||||
}
|
||||
|
||||
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
|
||||
|
||||
@@ -35,3 +35,20 @@ export function missingShippedPluginIds(
|
||||
.map((record) => record.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins this image's profile ships that the install has never seen and
|
||||
* that are not on disk. They get fetched from the registry once, the first
|
||||
* boot that can reach it.
|
||||
*/
|
||||
export function newShippedPluginIds(
|
||||
shipped: Iterable<string>,
|
||||
records: { id: string }[],
|
||||
presentIds: ReadonlySet<string>,
|
||||
ignored: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const known = new Set(records.map((record) => record.id));
|
||||
return [...shipped]
|
||||
.filter((id) => !known.has(id) && !presentIds.has(id) && !ignored.has(id))
|
||||
.sort();
|
||||
}
|
||||
@@ -18,7 +18,14 @@ export interface OnboardingDefaults {
|
||||
|
||||
type Recommended = boolean | "desktop";
|
||||
|
||||
export interface BundledCatalogEntry {
|
||||
tier: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface BundledIndex {
|
||||
profile?: string;
|
||||
catalog?: Record<string, BundledCatalogEntry>;
|
||||
plugins: Record<
|
||||
string,
|
||||
{
|
||||
@@ -45,7 +52,14 @@ export function readBundledIndex(): BundledIndex | null {
|
||||
);
|
||||
cached =
|
||||
raw && typeof raw.plugins === "object" && raw.plugins
|
||||
? { plugins: raw.plugins }
|
||||
? {
|
||||
plugins: raw.plugins,
|
||||
profile: typeof raw.profile === "string" ? raw.profile : undefined,
|
||||
catalog:
|
||||
raw.catalog && typeof raw.catalog === "object"
|
||||
? raw.catalog
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
cached = null;
|
||||
@@ -101,21 +115,45 @@ export function onboardingDefaults(pluginId: string): OnboardingDefaults {
|
||||
};
|
||||
}
|
||||
|
||||
const PROFILES = ["minimal", "standard", "full"];
|
||||
|
||||
/**
|
||||
* The state a newly seen plugin's row starts in. Bundled plugins start
|
||||
* enabled, except on a fresh install, where only recommended ones do and
|
||||
* consent plugins wait for the admin. A plugin dropped into the data
|
||||
* directory always starts disabled.
|
||||
* Plugins the image's profile ships (its tier and every smaller one), with
|
||||
* the version it pins. Read from the bundled index, so core never names them.
|
||||
*/
|
||||
export function shippedCatalog(): Map<string, BundledCatalogEntry> {
|
||||
const index = readBundledIndex();
|
||||
const out = new Map<string, BundledCatalogEntry>();
|
||||
if (!index?.catalog) return out;
|
||||
const rank = PROFILES.indexOf(index.profile ?? "standard");
|
||||
for (const [id, entry] of Object.entries(index.catalog)) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const tier = PROFILES.indexOf(entry.tier);
|
||||
if (tier === -1 || rank === -1 || tier > rank) continue;
|
||||
out.set(id, entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a shipped plugin should be on when an install first sees it. */
|
||||
export function startsEnabled(pluginId: string): boolean {
|
||||
// The default look is what the app is drawn with.
|
||||
if (isDefaultLookPlugin(pluginId)) return true;
|
||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||
return recommended && !consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a newly seen plugin's row starts in. A bundled plugin starts on
|
||||
* only when it is recommended, so a plugin a later release adds to the image
|
||||
* does not switch itself on everywhere. Consent plugins wait for the admin.
|
||||
* A plugin dropped into the data directory always starts disabled.
|
||||
*/
|
||||
export function initialPluginState(
|
||||
pluginId: string,
|
||||
source: "bundled" | "user",
|
||||
freshInstall: boolean,
|
||||
_freshInstall: boolean,
|
||||
): "enabled" | "disabled" {
|
||||
if (source !== "bundled") return "disabled";
|
||||
if (!freshInstall) return "enabled";
|
||||
// The default look is what the app is drawn with.
|
||||
if (isDefaultLookPlugin(pluginId)) return "enabled";
|
||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||
return recommended && !consent ? "enabled" : "disabled";
|
||||
return startsEnabled(pluginId) ? "enabled" : "disabled";
|
||||
}
|
||||
@@ -243,8 +243,10 @@ export async function initializePlugins(
|
||||
|
||||
const fresh = await seedPlugins(loaded, options.freshInstall === true);
|
||||
if (!fresh) {
|
||||
const { restoreMissingPlugins } = await import("./manage.js");
|
||||
const { installNewShippedPlugins, restoreMissingPlugins } =
|
||||
await import("./manage.js");
|
||||
loaded.push(...(await restoreMissingPlugins()));
|
||||
loaded.push(...(await installNewShippedPlugins()));
|
||||
}
|
||||
await syncCapabilityGrants(loaded);
|
||||
if (loaded.some((plugin) => plugin.source === "bundled")) {
|
||||
|
||||
@@ -17,7 +17,11 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { isDefaultLookPlugin } from "./bundled-index.js";
|
||||
import {
|
||||
isDefaultLookPlugin,
|
||||
shippedCatalog,
|
||||
startsEnabled,
|
||||
} from "./bundled-index.js";
|
||||
import { mayRunInSafeMode } from "./safe-mode.js";
|
||||
import {
|
||||
activatePlugin,
|
||||
@@ -26,7 +30,7 @@ import {
|
||||
registerLoadedPlugins,
|
||||
unloadPlugin,
|
||||
} from "./index.js";
|
||||
import { missingShippedPluginIds } from "./boot-state.js";
|
||||
import { missingShippedPluginIds, newShippedPluginIds } from "./boot-state.js";
|
||||
import type { LoadedPlugin, PluginLoader } from "./loader.js";
|
||||
import {
|
||||
getBundledPluginsDir,
|
||||
@@ -1099,6 +1103,67 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at boot on an existing install. A plugin this image's profile ships
|
||||
* that the install never had and that is not on disk is fetched from the
|
||||
* official registry and starts on only if it is recommended. Offline, nothing
|
||||
* is recorded, so the next start tries again.
|
||||
*/
|
||||
export function installNewShippedPlugins(): Promise<LoadedPlugin[]> {
|
||||
return serialized(async () => {
|
||||
if (await isLinkedDesktop()) return [];
|
||||
const catalog = shippedCatalog();
|
||||
if (catalog.size === 0) return [];
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
const { loader } = getPluginRuntime();
|
||||
const ids = newShippedPluginIds(
|
||||
catalog.keys(),
|
||||
await repository.listAll(),
|
||||
new Set(loader.list().map((plugin) => plugin.id)),
|
||||
await readUninstalled(),
|
||||
);
|
||||
const added: LoadedPlugin[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const { source, release } = await requireRelease(
|
||||
id,
|
||||
catalog.get(id)?.version,
|
||||
"stable",
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
);
|
||||
const plugin = await swapIn(id, { release, source });
|
||||
const enabled = startsEnabled(id);
|
||||
await repository.update(id, {
|
||||
registryId: source.id,
|
||||
tier: "bundled",
|
||||
state: enabled ? "enabled" : "disabled",
|
||||
lastError: null,
|
||||
});
|
||||
await grantDeclared(plugin, null);
|
||||
added.push(plugin);
|
||||
pluginLogger.info(
|
||||
`Added ${id}@${release.version} from the registry (${enabled ? "on" : "off"})`,
|
||||
{ operation: "plugin_install_shipped" },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pluginLogger.warn(`Could not add ${id}: ${message}`, {
|
||||
operation: "plugin_install_shipped",
|
||||
});
|
||||
// A half made row would hide the plugin from the next try.
|
||||
if (!loader.get(id)) await repository.delete(id).catch(() => false);
|
||||
if (
|
||||
error instanceof PluginManageError &&
|
||||
error.code === "REGISTRY_UNREACHABLE"
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlugin(
|
||||
pluginId: string,
|
||||
options: InstallOptions,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
missingShippedPluginIds,
|
||||
newShippedPluginIds,
|
||||
startablePluginIds,
|
||||
} from "../../plugins/boot-state.js";
|
||||
|
||||
@@ -38,3 +39,20 @@ describe("missingShippedPluginIds", () => {
|
||||
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("newShippedPluginIds", () => {
|
||||
it("lists shipped plugins the install never had and that are not on disk", () => {
|
||||
expect(
|
||||
newShippedPluginIds(
|
||||
["new", "known", "here", "removed", "another"],
|
||||
[{ id: "known" }],
|
||||
new Set(["here"]),
|
||||
new Set(["removed"]),
|
||||
),
|
||||
).toEqual(["another", "new"]);
|
||||
});
|
||||
|
||||
it("finds nothing for an empty catalog", () => {
|
||||
expect(newShippedPluginIds([], [], new Set(), new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,18 @@ import {
|
||||
isDefaultLookPlugin,
|
||||
onboardingDefaults,
|
||||
resetBundledIndexCache,
|
||||
shippedCatalog,
|
||||
} from "../../plugins/bundled-index.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
function writeIndex(plugins: Record<string, unknown>) {
|
||||
function writeIndex(
|
||||
plugins: Record<string, unknown>,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "bundled-index.json"),
|
||||
JSON.stringify({ version: 1, plugins }),
|
||||
JSON.stringify({ version: 1, plugins, ...extra }),
|
||||
);
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
@@ -89,9 +93,10 @@ describe("initialPluginState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enables every bundled plugin on an existing install", () => {
|
||||
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
|
||||
expect(initialPluginState("ask", "bundled", false)).toBe("enabled");
|
||||
it("enables only recommended plugins an existing install sees for the first time", () => {
|
||||
expect(initialPluginState("kept", "bundled", false)).toBe("enabled");
|
||||
expect(initialPluginState("other", "bundled", false)).toBe("disabled");
|
||||
expect(initialPluginState("ask", "bundled", false)).toBe("disabled");
|
||||
});
|
||||
|
||||
it("enables only recommended, non-consent plugins on a fresh install", () => {
|
||||
@@ -106,6 +111,40 @@ describe("initialPluginState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shippedCatalog", () => {
|
||||
it("is empty for an index without a catalog", () => {
|
||||
writeIndex({});
|
||||
expect(shippedCatalog().size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the pins of the image's tier and smaller ones", () => {
|
||||
writeIndex(
|
||||
{},
|
||||
{
|
||||
profile: "standard",
|
||||
catalog: {
|
||||
look: { tier: "minimal", version: "1.0.0" },
|
||||
ops: { tier: "standard", version: "1.0.0" },
|
||||
big: { tier: "full", version: "2.0.0" },
|
||||
odd: { tier: "huge" },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect([...shippedCatalog().keys()].sort()).toEqual(["look", "ops"]);
|
||||
});
|
||||
|
||||
it("follows a minimal image", () => {
|
||||
writeIndex(
|
||||
{},
|
||||
{
|
||||
profile: "minimal",
|
||||
catalog: { look: { tier: "minimal" }, ops: { tier: "standard" } },
|
||||
},
|
||||
);
|
||||
expect([...shippedCatalog().keys()]).toEqual(["look"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default look", () => {
|
||||
it("lists the plugins flagged as the default look", () => {
|
||||
writeIndex({
|
||||
|
||||
Reference in new issue
Block a user