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];
|
const staged = [...pinned, ...extra];
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(destination, "bundled-index.json"),
|
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({
|
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
|
||||||
version: 1,
|
version: 1,
|
||||||
|
profile: "standard",
|
||||||
plugins: { a: { onboarding: { recommended: true, consent: false } } },
|
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"],
|
["look", "plain"],
|
||||||
),
|
),
|
||||||
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
|
).toMatchObject({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||||
|
|
||||||
const index = {
|
const index = {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@@ -236,12 +236,22 @@ async function extractArtifact(buffer, outDir, id) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* What dist/plugins/bundled-index.json holds: the onboarding defaults of each
|
* 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 staged = new Set(stagedIds);
|
||||||
const out = {};
|
const out = {};
|
||||||
|
const catalog = {};
|
||||||
for (const plugin of plugins) {
|
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 (!staged.has(plugin.id)) continue;
|
||||||
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
||||||
out[plugin.id] = {
|
out[plugin.id] = {
|
||||||
@@ -249,7 +259,7 @@ function buildBundledIndex(plugins, stagedIds) {
|
|||||||
...(plugin.defaultLook ? { defaultLook: true } : {}),
|
...(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. */
|
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
|
||||||
|
|||||||
@@ -35,3 +35,20 @@ export function missingShippedPluginIds(
|
|||||||
.map((record) => record.id)
|
.map((record) => record.id)
|
||||||
.sort();
|
.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";
|
type Recommended = boolean | "desktop";
|
||||||
|
|
||||||
|
export interface BundledCatalogEntry {
|
||||||
|
tier: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BundledIndex {
|
interface BundledIndex {
|
||||||
|
profile?: string;
|
||||||
|
catalog?: Record<string, BundledCatalogEntry>;
|
||||||
plugins: Record<
|
plugins: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -45,7 +52,14 @@ export function readBundledIndex(): BundledIndex | null {
|
|||||||
);
|
);
|
||||||
cached =
|
cached =
|
||||||
raw && typeof raw.plugins === "object" && raw.plugins
|
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;
|
: null;
|
||||||
} catch {
|
} catch {
|
||||||
cached = null;
|
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
|
* Plugins the image's profile ships (its tier and every smaller one), with
|
||||||
* enabled, except on a fresh install, where only recommended ones do and
|
* the version it pins. Read from the bundled index, so core never names them.
|
||||||
* consent plugins wait for the admin. A plugin dropped into the data
|
*/
|
||||||
* directory always starts disabled.
|
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(
|
export function initialPluginState(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
source: "bundled" | "user",
|
source: "bundled" | "user",
|
||||||
freshInstall: boolean,
|
_freshInstall: boolean,
|
||||||
): "enabled" | "disabled" {
|
): "enabled" | "disabled" {
|
||||||
if (source !== "bundled") return "disabled";
|
if (source !== "bundled") return "disabled";
|
||||||
if (!freshInstall) return "enabled";
|
return startsEnabled(pluginId) ? "enabled" : "disabled";
|
||||||
// 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";
|
|
||||||
}
|
}
|
||||||
@@ -243,8 +243,10 @@ export async function initializePlugins(
|
|||||||
|
|
||||||
const fresh = await seedPlugins(loaded, options.freshInstall === true);
|
const fresh = await seedPlugins(loaded, options.freshInstall === true);
|
||||||
if (!fresh) {
|
if (!fresh) {
|
||||||
const { restoreMissingPlugins } = await import("./manage.js");
|
const { installNewShippedPlugins, restoreMissingPlugins } =
|
||||||
|
await import("./manage.js");
|
||||||
loaded.push(...(await restoreMissingPlugins()));
|
loaded.push(...(await restoreMissingPlugins()));
|
||||||
|
loaded.push(...(await installNewShippedPlugins()));
|
||||||
}
|
}
|
||||||
await syncCapabilityGrants(loaded);
|
await syncCapabilityGrants(loaded);
|
||||||
if (loaded.some((plugin) => plugin.source === "bundled")) {
|
if (loaded.some((plugin) => plugin.source === "bundled")) {
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
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 { mayRunInSafeMode } from "./safe-mode.js";
|
||||||
import {
|
import {
|
||||||
activatePlugin,
|
activatePlugin,
|
||||||
@@ -26,7 +30,7 @@ import {
|
|||||||
registerLoadedPlugins,
|
registerLoadedPlugins,
|
||||||
unloadPlugin,
|
unloadPlugin,
|
||||||
} from "./index.js";
|
} from "./index.js";
|
||||||
import { missingShippedPluginIds } from "./boot-state.js";
|
import { missingShippedPluginIds, newShippedPluginIds } from "./boot-state.js";
|
||||||
import type { LoadedPlugin, PluginLoader } from "./loader.js";
|
import type { LoadedPlugin, PluginLoader } from "./loader.js";
|
||||||
import {
|
import {
|
||||||
getBundledPluginsDir,
|
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(
|
export function updatePlugin(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
options: InstallOptions,
|
options: InstallOptions,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
missingShippedPluginIds,
|
missingShippedPluginIds,
|
||||||
|
newShippedPluginIds,
|
||||||
startablePluginIds,
|
startablePluginIds,
|
||||||
} from "../../plugins/boot-state.js";
|
} from "../../plugins/boot-state.js";
|
||||||
|
|
||||||
@@ -38,3 +39,20 @@ describe("missingShippedPluginIds", () => {
|
|||||||
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
|
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,
|
isDefaultLookPlugin,
|
||||||
onboardingDefaults,
|
onboardingDefaults,
|
||||||
resetBundledIndexCache,
|
resetBundledIndexCache,
|
||||||
|
shippedCatalog,
|
||||||
} from "../../plugins/bundled-index.js";
|
} from "../../plugins/bundled-index.js";
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
|
||||||
function writeIndex(plugins: Record<string, unknown>) {
|
function writeIndex(
|
||||||
|
plugins: Record<string, unknown>,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, "bundled-index.json"),
|
path.join(dir, "bundled-index.json"),
|
||||||
JSON.stringify({ version: 1, plugins }),
|
JSON.stringify({ version: 1, plugins, ...extra }),
|
||||||
);
|
);
|
||||||
resetBundledIndexCache();
|
resetBundledIndexCache();
|
||||||
}
|
}
|
||||||
@@ -89,9 +93,10 @@ describe("initialPluginState", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables every bundled plugin on an existing install", () => {
|
it("enables only recommended plugins an existing install sees for the first time", () => {
|
||||||
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
|
expect(initialPluginState("kept", "bundled", false)).toBe("enabled");
|
||||||
expect(initialPluginState("ask", "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", () => {
|
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", () => {
|
describe("default look", () => {
|
||||||
it("lists the plugins flagged as the default look", () => {
|
it("lists the plugins flagged as the default look", () => {
|
||||||
writeIndex({
|
writeIndex({
|
||||||
|
|||||||
Reference in new issue
Block a user