chore: move plugins to their own repos and drop the plugins folder

This commit is contained in:
LukeGus committed 2026-10-04 22:40:06 -05:00
1 parent 29a946d02e
commit e02b5fe6e4
3167 files changed
+286 -440749

No files matched your search

-26
View File
@@ -1,26 +0,0 @@
/**
* Applies every workspace plugin's dependency patches after npm install, so
* the Vite dev server serves the same patched libraries a build bundles.
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
const plugins = path.join(root, "plugins");
for (const id of fs.readdirSync(plugins)) {
const dir = path.join(plugins, id);
const pkgPath = path.join(dir, "package.json");
if (!fs.existsSync(pkgPath)) continue;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (!pkg.termix?.patches?.length && !fs.existsSync(path.join(dir, "patches")))
continue;
const result = spawnSync(process.execPath, [cli, "patch"], {
cwd: dir,
stdio: "inherit",
});
if (result.status !== 0) process.exit(result.status ?? 1);
}
+6 -49
View File
@@ -3,14 +3,12 @@
*
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
* A workspace plugin is built from plugins/<id>; a tmxplug plugin is
* downloaded (or read from a path), checked against its pinned sha256 and
* unpacked. Either way only what a server needs at runtime lands there.
* Each .tmxplug is downloaded (or read from a path), checked against its
* pinned sha256 and unpacked.
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
loadBundledPlugins,
fetchArtifact,
@@ -18,59 +16,18 @@ const {
} = require("./lib/bundled-plugins.cjs");
const root = path.resolve(__dirname, "..");
const source = path.join(root, "plugins");
const destination = path.join(root, "dist", "plugins");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
// Same list as a .tmxplug, see packages/plugin-sdk/cli/lib/tmxplug.mjs.
const SHIPPED = [
"manifest.json",
"dist",
"locales",
"migrations",
"README.md",
"CHANGELOG.json",
"icon.svg",
];
function stageWorkspace(id) {
const pluginDir = path.join(source, id);
execFileSync(process.execPath, [cli, "build"], {
cwd: pluginDir,
stdio: "inherit",
});
if (!fs.existsSync(path.join(pluginDir, "dist", "backend.js"))) {
throw new Error(`${id} produced no dist/backend.js`);
}
const outDir = path.join(destination, id);
fs.mkdirSync(outDir, { recursive: true });
for (const entry of SHIPPED) {
const from = path.join(pluginDir, entry);
if (!fs.existsSync(from)) continue;
fs.cpSync(from, path.join(outDir, entry), { recursive: true });
}
}
async function main() {
const plugins = loadBundledPlugins(root);
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
for (const plugin of plugins) {
if (plugin.source === "workspace") {
stageWorkspace(plugin.id);
} else {
const buffer = await fetchArtifact(plugin, root);
await extractArtifact(
buffer,
path.join(destination, plugin.id),
plugin.id,
);
console.log(`unpacked ${plugin.id}`);
}
const buffer = await fetchArtifact(plugin, root);
await extractArtifact(buffer, path.join(destination, plugin.id), plugin.id);
console.log(`unpacked ${plugin.id}`);
}
console.log(
-159
View File
@@ -1,159 +0,0 @@
/**
* Writes src/backend/tests/fixtures/upgrade/{db.sqlite,fixture.env,files/}: a 2.8 install's
* data directory with every feature table filled. The schema is 2.8's own
* dump, the rows come from rows.ts, and the columns 2.8 encrypted are
* encrypted the way 2.8 did it (a v3 system-wrapped data key per user, then
* FieldCrypto per field), so the upgrade test has to really decrypt them.
*
* Usage: npx tsx scripts/build-upgrade-fixture.ts
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
import bcrypt from "bcryptjs";
import { FieldCrypto } from "../src/backend/utils/field-crypto.js";
import {
ENCRYPTED_COLUMNS,
FIXTURE_PASSWORD,
RECORDING_FILES,
ROWS,
type Row,
} from "../src/backend/tests/fixtures/upgrade/rows.js";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const FIXTURES = path.join(ROOT, "src/backend/tests/fixtures");
const OUT = path.join(FIXTURES, "upgrade");
const hex = (label: string) =>
crypto
.createHash("sha256")
.update(`termix-d4-fixture:${label}`)
.digest("hex");
const ENV = {
JWT_SECRET: hex("jwt") + hex("jwt-2"),
DATABASE_KEY: hex("database"),
ENCRYPTION_KEY: hex("encryption"),
INTERNAL_AUTH_TOKEN: hex("internal"),
};
const masterKey = Buffer.from(ENV.ENCRYPTION_KEY, "hex");
/** UserKeyManager's v3 wrap, unchanged since 2.8. */
function wrapDek(userId: string, dek: Buffer): string {
const wrapKey = Buffer.from(
crypto.hkdfSync(
"sha256",
masterKey,
Buffer.alloc(0),
`termix:dek-wrap:v3:${userId}`,
32,
),
);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", wrapKey, iv);
cipher.setAAD(Buffer.from(userId, "utf8"));
const ct = Buffer.concat([cipher.update(dek), cipher.final()]);
return JSON.stringify({
v: 3,
alg: "aes-256-gcm",
iv: iv.toString("base64"),
ct: ct.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
createdAt: "2026-05-01T10:00:00.000Z",
});
}
const deks = new Map<string, Buffer>();
const dekFor = (userId: string) => {
let dek = deks.get(userId);
if (!dek) {
dek = Buffer.from(hex(`dek:${userId}`), "hex");
deks.set(userId, dek);
}
return dek;
};
function prepareRow(table: string, row: Row): Row {
const out: Row = { ...row };
if (table === "users" && out.password_hash === undefined) {
out.password_hash = bcrypt.hashSync(FIXTURE_PASSWORD, 10);
}
for (const [encTable, column, field, userColumn] of ENCRYPTED_COLUMNS) {
if (encTable !== table || typeof out[column] !== "string" || !out[column])
continue;
const userId = String(out[userColumn]);
const recordId = table === "users" ? userId : String(out.id);
out[column] = FieldCrypto.encryptField(
out[column] as string,
dekFor(userId),
recordId,
field,
);
}
return out;
}
function insert(sqlite: Database.Database, table: string, row: Row): void {
const keys = Object.keys(row);
sqlite
.prepare(
`INSERT INTO "${table}" (${keys.map((key) => `"${key}"`).join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`,
)
.run(...keys.map((key) => row[key]));
}
fs.mkdirSync(OUT, { recursive: true });
const dbPath = path.join(OUT, "db.sqlite");
fs.rmSync(dbPath, { force: true });
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = DELETE");
sqlite.exec(
fs.readFileSync(path.join(FIXTURES, "sqlite-2.8-schema.sql"), "utf8"),
);
sqlite.pragma("foreign_keys = ON");
sqlite.transaction(() => {
for (const [table, rows] of Object.entries(ROWS)) {
for (const row of rows) insert(sqlite, table, prepareRow(table, row));
}
for (const user of ROWS.users) {
const userId = String(user.id);
insert(sqlite, "settings", {
key: `user_dek_v3_${userId}`,
value: wrapDek(userId, dekFor(userId)),
});
}
})();
const violations = sqlite.pragma("foreign_key_check") as unknown[];
if (violations.length > 0) {
throw new Error(`Foreign key violations: ${JSON.stringify(violations)}`);
}
sqlite.exec("VACUUM");
sqlite.close();
fs.writeFileSync(
path.join(OUT, "fixture.env"),
Object.entries(ENV)
.map(([key, value]) => `${key}=${value}`)
.join("\n") + "\n",
);
// The recording files 2.8 left in DATA_DIR, which the rows point at.
const files = path.join(OUT, "files");
fs.rmSync(files, { recursive: true, force: true });
for (const [kind, relative] of Object.entries(RECORDING_FILES)) {
const file = path.join(files, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
`d4 ${kind} recording
`,
);
}
console.log(`Wrote ${path.relative(ROOT, dbPath)} and fixture.env`);
+30 -54
View File
@@ -39,70 +39,46 @@ const sha256 = (buffer: Buffer) =>
crypto.createHash("sha256").update(buffer).digest("hex");
describe("parseBundledPlugins", () => {
it("accepts workspace and tmxplug entries", () => {
const { plugins, problems } = parseBundledPlugins(
{
plugins: [
{ id: "one", source: "workspace" },
{
id: "two",
source: "tmxplug",
url: "https://x/two.tmxplug",
sha256: SHA,
},
{
id: "three",
source: "tmxplug",
path: "vendor/three.tmxplug",
sha256: SHA,
},
],
},
["one"],
);
it("accepts tmxplug entries", () => {
const { plugins, problems } = parseBundledPlugins({
plugins: [
{
id: "two",
source: "tmxplug",
url: "https://x/two.tmxplug",
sha256: SHA,
},
{
id: "three",
source: "tmxplug",
path: "vendor/three.tmxplug",
sha256: SHA,
},
],
});
expect(problems).toEqual([]);
expect(plugins.map((p: { id: string }) => p.id)).toEqual([
"one",
"two",
"three",
]);
});
it("reports a plugins folder nobody listed", () => {
const { problems } = parseBundledPlugins(
{ plugins: [{ id: "one", source: "workspace" }] },
["one", "stray"],
);
expect(problems).toEqual([
"plugins/stray is not listed in docker/bundled-plugins.json",
]);
expect(plugins.map((p: { id: string }) => p.id)).toEqual(["two", "three"]);
});
it("reports bad entries", () => {
const { problems } = parseBundledPlugins(
{
plugins: [
{ id: "gone", source: "workspace" },
{ id: "nohash", source: "tmxplug", url: "https://x" },
{ id: "plain", source: "tmxplug", url: "http://x", sha256: SHA },
{ id: "still-here", source: "tmxplug", path: "a", sha256: SHA },
{ id: "gone", source: "workspace" },
{ id: "odd", source: "npm" },
],
},
["still-here"],
);
const { problems } = parseBundledPlugins({
plugins: [
{ id: "nohash", source: "tmxplug", url: "https://x" },
{ id: "plain", source: "tmxplug", url: "http://x", sha256: SHA },
{ id: "ok", source: "tmxplug", path: "a", sha256: SHA },
{ id: "ok", source: "tmxplug", path: "a", sha256: SHA },
{ id: "odd", source: "workspace" },
],
});
expect(problems).toEqual([
"gone is a workspace plugin but plugins/gone does not exist",
"nohash needs a lowercase hex sha256",
"plain needs exactly one of an https url or a path",
"still-here comes from a .tmxplug, so delete plugins/still-here",
"gone is listed twice",
'odd has an unknown source "npm"',
"ok is listed twice",
'odd has an unknown source "workspace"',
]);
});
it("matches the plugins folder in this repo", () => {
it("reads the list in this repo", () => {
const root = path.resolve(__dirname, "..");
expect(() => loadBundledPlugins(root)).not.toThrow();
});
+51 -57
View File
@@ -2,18 +2,15 @@
/**
* Core knows no plugin by name.
*
* Fails when anything under src/ imports from plugins/, or spells a plugin id:
* a bare literal ("docker"), a plugin route ("/plugin-api/docker/..."), or an
* action, slot or permission id that starts with one ("docker.open"). The
* shell (src/ui) also may not spell a view a plugin owns (a tab, panel or
* dashboard card id from a manifest). What core needs from a plugin comes
* through the registries instead. Regex literals are read too, so
* /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
* Fails when anything under src/ spells a plugin id: a bare literal
* ("docker"), a plugin route ("/plugin-api/docker/..."), or an action, slot
* or permission id that starts with one ("docker.open"). What core needs from
* a plugin comes through the registries instead. Regex literals are read too,
* so /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
*
* Ids come from every plugin manifest and from docker/bundled-plugins.json, so
* the check still means something once plugins live in their own repos. An
* action, slot or extension point id a plugin owns ("terminal.open") counts
* too, whatever its prefix. electron/ and vite.config.ts are read as well.
* Plugins live in their own repos, so the ids are the official ones below
* plus anything in docker/bundled-plugins.json. electron/ and vite.config.ts
* are read as well.
*
* src/backend/tests, src/ui/tests and src/ui/locales are exempt, and so is
* src/backend/upgrade/: the one-time
@@ -29,16 +26,50 @@
const fs = require("node:fs");
const path = require("node:path");
const { owners } = require("./check-plugin-uses.cjs");
const SSH_TERMS = new Set(["totp"]);
const OFFICIAL_IDS = [
"acme-ssl",
"ai",
"alerts",
"automations",
"docker",
"file-manager",
"fleets",
"homepage",
"host-metrics",
"ldap",
"network-topology",
"opkssh",
"proxmox",
"remote-desktop",
"secret-sources",
"serial",
"session-recording",
"session-sharing",
"snippets",
"ssh-terminal",
"sso",
"step-ca",
"tailscale",
"telemetry",
"termix-identity",
"tmux-monitor",
"totp",
"tunnels",
"vault",
"wake-on-lan",
"warpgate",
"web-endpoint",
"webauthn",
"workspaces",
];
function paths(root) {
const src = path.join(root, "src");
return {
root,
src,
ui: path.join(src, "ui"),
plugins: path.join(root, "plugins"),
bundled: path.join(root, "docker", "bundled-plugins.json"),
extra: [path.join(root, "electron"), path.join(root, "vite.config.ts")],
exempt: [
@@ -51,37 +82,15 @@ function paths(root) {
};
}
function manifests(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dir, entry.name, "manifest.json"))
.filter((file) => fs.existsSync(file))
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
}
function names(pluginsDir, bundledFile) {
const ids = new Set();
const views = new Set();
function pluginIds(bundledFile) {
const ids = new Set(OFFICIAL_IDS);
if (bundledFile && fs.existsSync(bundledFile)) {
for (const entry of JSON.parse(fs.readFileSync(bundledFile, "utf8"))
.plugins ?? []) {
if (typeof entry?.id === "string") ids.add(entry.id);
}
}
for (const manifest of manifests(pluginsDir)) {
ids.add(manifest.id);
const contributes = manifest.contributes ?? {};
for (const list of [
contributes.tabs,
contributes.panels,
contributes.dashboardCards,
]) {
for (const view of list ?? []) views.add(view.id);
}
}
return { ids, views };
return ids;
}
function walk(dir, exempt, out) {
@@ -131,9 +140,8 @@ function regexLiterals(source) {
}
function scan(root = path.resolve(__dirname, "..")) {
const { src, ui, plugins, bundled, extra, exempt, sshConnect } = paths(root);
const { ids, views } = names(plugins, bundled);
const owned = fs.existsSync(plugins) ? owners(plugins) : new Map();
const { src, bundled, extra, exempt, sshConnect } = paths(root);
const ids = pluginIds(bundled);
const found = {};
const add = (file, what) => {
const key = path.relative(root, file).replaceAll("\\", "/");
@@ -150,23 +158,10 @@ function scan(root = path.resolve(__dirname, "..")) {
.split("\n")
.filter((line) => !/plugin-id-ok: \S/.test(line))
.join("\n");
const inShell = file.startsWith(ui + path.sep);
const inConnect = file.startsWith(sshConnect + path.sep);
for (const match of source.matchAll(
/(?:from|import)\s*\(?\s*["']([^"']+)["']/g,
)) {
const target = match[1].startsWith(".")
? path.resolve(path.dirname(file), match[1])
: "";
if (target.startsWith(plugins + path.sep)) {
add(file, `import ${match[1]}`);
}
}
for (const text of literals(source)) {
const sshTerm = inConnect && SSH_TERMS.has(text);
if ((ids.has(text) && !sshTerm) || (inShell && views.has(text))) {
add(file, text);
}
if (ids.has(text) && !sshTerm) add(file, text);
for (const route of text.matchAll(
/\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g,
)) {
@@ -174,7 +169,6 @@ function scan(root = path.resolve(__dirname, "..")) {
}
const prefix = /^([a-z][a-z0-9-]*)\.[a-zA-Z]/.exec(text);
if (prefix && ids.has(prefix[1])) add(file, text);
if (owned.has(text)) add(file, `${text} (owned by ${owned.get(text)})`);
}
for (const pattern of regexLiterals(source)) {
for (const route of pattern.matchAll(
+12 -24
View File
@@ -19,14 +19,7 @@ afterEach(() => {
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "core-plugin-ids-"));
const all = {
"plugins/docker/manifest.json": JSON.stringify({
id: "docker",
contributes: { tabs: [{ id: "docker-tab" }] },
}),
...files,
};
for (const [file, content] of Object.entries(all)) {
for (const [file, content] of Object.entries(files)) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
@@ -69,40 +62,35 @@ describe("scan", () => {
});
});
it("fails a bare id, and a view id in the shell", () => {
it("fails a bare id and an id-prefixed action, but not in tests", () => {
const dir = fixture({
"src/backend/id.ts": 'if (pluginId === "docker") {}\n',
"src/ui/view.ts": 'open("docker-tab");\n',
"src/ui/action.ts": 'run("docker.open");\n',
"src/ui/tests/skip.ts": 'open("docker");\n',
});
expect(scan(dir)).toEqual({
"src/backend/id.ts": ["docker"],
"src/ui/view.ts": ["docker-tab"],
"src/ui/action.ts": ["docker.open"],
});
});
});
describe("scan beyond src with plugins elsewhere", () => {
it("knows the bundled ids even when plugins/ is empty", () => {
describe("scan beyond src", () => {
it("knows ids from bundled-plugins.json", () => {
const dir = fixture({
"docker/bundled-plugins.json": JSON.stringify({
plugins: [{ id: "tunnels", source: "workspace" }],
plugins: [{ id: "extra-thing", source: "tmxplug" }],
}),
"src/ui/x.ts": 'const route = "/plugin-ws/tunnels/c2s";\n',
"src/ui/x.ts": 'const route = "/plugin-ws/extra-thing/c2s";\n',
});
fs.rmSync(path.join(dir, "plugins"), { recursive: true, force: true });
expect(scan(dir)["src/ui/x.ts"]).toContain("/plugin-ws/tunnels");
expect(scan(dir)["src/ui/x.ts"]).toContain("/plugin-ws/extra-thing");
});
it("reads electron and catches an action a plugin owns", () => {
it("reads electron", () => {
const dir = fixture({
"plugins/docker/src/frontend/index.tsx":
'app.registerAction("terminal.open", fn);\n',
"electron/main.cjs": 'invoke("terminal.open");\n',
"electron/main.cjs": 'invoke("/plugin-api/tunnels/list");\n',
});
expect(scan(dir)["electron/main.cjs"]).toEqual([
"terminal.open (owned by docker)",
]);
expect(scan(dir)["electron/main.cjs"]).toEqual(["/plugin-api/tunnels"]);
});
it("skips a line marked plugin-id-ok with a reason", () => {
-228
View File
@@ -1,228 +0,0 @@
#!/usr/bin/env node
/**
* A plugin's strings live in its own locales/en.json.
*
* A plugin's i18next namespace falls back to core's, so a plugin can use
* `common.*` without copying it. Any other key a plugin uses has to be in the
* plugin's own en.json: one that only resolves through the fallback breaks
* the moment core renames or drops it, and core cannot tell it is in use.
*
* Reads t("...") calls and i18n key props (titleKey, labelKey and the rest)
* in plugins/<id>/src, and the *Key values in plugins/<id>/manifest.json.
* A key with a namespace ("docker:hosts.x") is checked against that plugin.
*
* --list prints what is missing per plugin, with whether core has the key.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const PLUGINS = path.join(ROOT, "plugins");
const CORE_LOCALE = path.join(ROOT, "src", "ui", "locales", "en.json");
const SHARED_PREFIXES = ["common."];
const KEY = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
const KEY_PROPS = new Set([
"titleKey",
"labelKey",
"descriptionKey",
"noHostMessageKey",
"paletteKey",
"hintKey",
"messageKey",
"placeholderKey",
"tooltipKey",
"i18nKey",
"enableLabelKey",
"enableDescriptionKey",
"consequenceKey",
"groupKey",
"emptyKey",
"nameKey",
]);
const PLURAL_SUFFIXES = [
"_zero",
"_one",
"_two",
"_few",
"_many",
"_other",
"_plural",
];
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
/**
* Whether a dotted key resolves in a locale tree, the way i18next finds it:
* a segment may itself hold dots ("services.use"), and a last segment may be
* a plural form.
*/
function hasKey(tree, key) {
const find = (node, parts) => {
if (parts.length === 0) return node !== undefined;
if (!node || typeof node !== "object") return false;
for (let end = parts.length; end >= 1; end--) {
const name = parts.slice(0, end).join(".");
if (name in node && find(node[name], parts.slice(end))) return true;
if (
end === parts.length &&
PLURAL_SUFFIXES.some((suffix) => `${name}${suffix}` in node)
) {
return true;
}
}
return false;
};
return find(tree, key.split("."));
}
function isShared(key) {
return SHARED_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(full);
}
return out;
}
/** Drops comments, so an example in one is not read as a call. */
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/**
* Every literal i18n key a source file uses. A prefix a key is built from at
* run time ends in ".*".
*/
function keysInSource(text) {
const source = stripComments(text);
const keys = new Set();
for (const match of source.matchAll(
/\bt\(\s*(["'])((?:(?!\1)[^\\\n$`])+)\1/g,
)) {
keys.add(match[2]);
}
for (const match of source.matchAll(
/\b([A-Za-z]+Key)\s*[:=]\s*\{?\s*(["'])((?:(?!\2)[^\\\n])+)\2/g,
)) {
if (KEY_PROPS.has(match[1])) keys.add(match[3]);
}
// A key built at run time: `hosts.sharing.levels.${level}.label` needs the
// whole hosts.sharing.levels subtree. A one-segment prefix says too little
// to check.
for (const match of source.matchAll(
/\bt\(\s*`((?:[A-Za-z0-9_-]+\.){2,})\$\{/g,
)) {
keys.add(`${match[1]}*`);
}
return [...keys].filter((key) =>
KEY.test(key.replace(/^[a-z0-9-]+:/, "").replace(/\.\*$/, "")),
);
}
function keysInManifest(value, out = new Set()) {
if (Array.isArray(value)) {
for (const item of value) keysInManifest(item, out);
} else if (value && typeof value === "object") {
for (const [name, child] of Object.entries(value)) {
if (KEY_PROPS.has(name) && typeof child === "string") out.add(child);
else keysInManifest(child, out);
}
}
return out;
}
function pluginIds(pluginsDir) {
return fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((id) => fs.existsSync(path.join(pluginsDir, id, "manifest.json")))
.sort();
}
/**
* Keys each plugin uses but does not define, other than the shared ones.
* Returns { [pluginId]: [{ key, file, inCore }] }.
*/
function scan({ pluginsDir = PLUGINS, coreLocale = CORE_LOCALE } = {}) {
const core = fs.existsSync(coreLocale) ? readJson(coreLocale) : {};
const ids = pluginIds(pluginsDir);
const locales = new Map(
ids.map((id) => {
const file = path.join(pluginsDir, id, "locales", "en.json");
return [id, fs.existsSync(file) ? readJson(file) : {}];
}),
);
const missing = {};
const check = (owner, rawKey, file) => {
const colon = rawKey.indexOf(":");
const namespace = colon > 0 ? rawKey.slice(0, colon) : owner;
const key = colon > 0 ? rawKey.slice(colon + 1) : rawKey;
if (isShared(key)) return;
const tree = locales.get(namespace);
const lookup = key.endsWith(".*") ? key.slice(0, -2) : key;
if (tree && hasKey(tree, lookup)) return;
const inCore = hasKey(core, lookup);
// A bare word that is nowhere is a local helper named t, not a string.
if (!key.includes(".") && !inCore) return;
const list = (missing[owner] ??= []);
if (!list.some((entry) => entry.key === rawKey)) {
list.push({
key: rawKey,
file: path.relative(ROOT, file).replaceAll("\\", "/"),
inCore,
});
}
};
for (const id of ids) {
const manifestFile = path.join(pluginsDir, id, "manifest.json");
for (const key of keysInManifest(readJson(manifestFile))) {
if (KEY.test(key.replace(/^[a-z0-9-]+:/, ""))) {
check(id, key, manifestFile);
}
}
for (const file of walk(path.join(pluginsDir, id, "src"))) {
for (const key of keysInSource(fs.readFileSync(file, "utf8"))) {
check(id, key, file);
}
}
}
for (const list of Object.values(missing)) {
list.sort((a, b) => a.key.localeCompare(b.key));
}
return missing;
}
function main() {
const missing = scan();
if (process.argv.includes("--list")) {
console.log(JSON.stringify(missing, null, 2));
return;
}
const entries = Object.entries(missing);
if (entries.length === 0) return;
console.error(
"Plugins use i18n keys their own locales/en.json does not define:",
);
for (const [id, list] of entries) {
for (const { key, file } of list) {
console.error(` ${id}: ${key} (${file})`);
}
}
process.exit(1);
}
if (require.main === module) main();
module.exports = { scan, keysInSource, hasKey };
-127
View File
@@ -1,127 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { scan, keysInSource, hasKey } =
require("./check-plugin-locales.cjs") as {
scan: (options: {
pluginsDir: string;
coreLocale: string;
}) => Record<string, Array<{ key: string; file: string; inCore: boolean }>>;
keysInSource: (source: string) => string[];
hasKey: (tree: unknown, key: string) => boolean;
};
let dir: string | null = null;
afterEach(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
dir = null;
});
function fixture(files: Record<string, unknown>) {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-locales-"));
for (const [file, content] of Object.entries(files)) {
const full = path.join(dir, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(
full,
typeof content === "string" ? content : JSON.stringify(content),
);
}
return {
pluginsDir: path.join(dir, "plugins"),
coreLocale: path.join(dir, "core.json"),
};
}
describe("keysInSource", () => {
it("reads t() calls, key props and run time prefixes, not comments", () => {
const keys = keysInSource(
[
'const a = t("hosts.one");',
"const b = i18next.t('docker:hosts.two', { x: 1 });",
'app.registerTab("x", X, { titleKey: "nav.three" });',
'<EmptyState messageKey="hosts.four" />',
"t(`hosts.sharing.levels.${level}.label`);",
"t(`hosts.${label}`);",
'// t("hosts.commented")',
'storageKey: "not.a.string.key"',
].join("\n"),
);
expect(keys.sort()).toEqual(
[
"docker:hosts.two",
"hosts.four",
"hosts.one",
"hosts.sharing.levels.*",
"nav.three",
].sort(),
);
});
});
describe("hasKey", () => {
it("finds nested keys, dotted names and plural forms", () => {
const tree = {
a: { b: "x" },
permissions: { "services.use": { title: "t" } },
items: { count_one: "1", count_other: "n" },
};
expect(hasKey(tree, "a.b")).toBe(true);
expect(hasKey(tree, "a")).toBe(true);
expect(hasKey(tree, "permissions.services.use.title")).toBe(true);
expect(hasKey(tree, "items.count")).toBe(true);
expect(hasKey(tree, "a.c")).toBe(false);
});
});
describe("scan", () => {
it("passes keys a plugin defines and the shared common ones", () => {
const options = fixture({
"core.json": { common: { save: "Save" }, hosts: { name: "Name" } },
"plugins/demo/manifest.json": { id: "demo", titleKey: "title" },
"plugins/demo/locales/en.json": { title: "Demo", hosts: { name: "N" } },
"plugins/demo/src/frontend/index.tsx":
't("common.save"); t("hosts.name"); t("title");',
});
expect(scan(options)).toEqual({});
});
it("fails a key that only resolves through core's fallback", () => {
const options = fixture({
"core.json": { hosts: { name: "Name" } },
"plugins/demo/manifest.json": { id: "demo" },
"plugins/demo/locales/en.json": {},
"plugins/demo/src/frontend/index.tsx": 't("hosts.name");',
});
expect(scan(options)).toEqual({
demo: [
{
key: "hosts.name",
file: expect.stringContaining("index.tsx"),
inCore: true,
},
],
});
});
it("checks a namespaced key against that plugin and a prefix as a subtree", () => {
const options = fixture({
"core.json": { hosts: { levels: { view: "View" } } },
"plugins/demo/manifest.json": { id: "demo" },
"plugins/demo/locales/en.json": {},
"plugins/demo/src/frontend/index.tsx":
't("other:known.key"); t("other:gone.key"); t(`hosts.levels.${l}`);',
"plugins/other/manifest.json": { id: "other" },
"plugins/other/locales/en.json": { known: { key: "k" } },
});
expect(scan(options).demo.map((entry) => entry.key)).toEqual([
"hosts.levels.*",
"other:gone.key",
]);
});
});
-153
View File
@@ -1,153 +0,0 @@
#!/usr/bin/env node
/**
* A plugin that calls another plugin's action, fills its slot or extends its
* extension point has to say so in manifest.json contributes.uses. Nothing
* else records that coupling: the ids are plain strings, so without it a
* plugin moved to its own repo silently loses a feature when the other one is
* renamed, and nobody can tell what depends on what.
*
* Owners are read from the code: a plugin owns the actions it registers, the
* slots it renders (ActionSlot/ComponentSlot/useSlotContributions/
* declareActionSlot) and the extension points it reads (useExtensions). An id
* nobody owns is core's and needs no declaration.
*
* --list prints what each plugin uses from which owner.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const PLUGINS = path.join(ROOT, "plugins");
// Each pattern captures a string literal or a constant's name; constants are
// resolved against the plugin's own `const NAME = "id"` declarations.
const ID = String.raw`(?:"([^"]+)"|([A-Z][A-Z0-9_]*))`;
const OWNS = [
new RegExp(String.raw`registerAction\(\s*${ID}`, "g"),
new RegExp(String.raw`declareActionSlot\(\s*\{\s*id:\s*${ID}`, "g"),
new RegExp(
String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`,
"g",
),
new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"),
new RegExp(String.raw`useExtensions(?:<[^>]*>)?\(\s*${ID}`, "g"),
new RegExp(String.raw`getExtension\(\s*${ID}`, "g"),
];
const USES = [
new RegExp(String.raw`invokeAction\(\s*${ID}`, "g"),
new RegExp(String.raw`registerSlotContribution\(\s*${ID}`, "g"),
new RegExp(String.raw`registerExtension\(\s*${ID}`, "g"),
new RegExp(
String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`,
"g",
),
new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"),
];
function sourceOf(dir) {
let text = "";
const walk = (current) => {
if (!fs.existsSync(current)) return;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const file = path.join(current, entry.name);
if (entry.isDirectory()) walk(file);
else if (/\.(ts|tsx)$/.test(entry.name)) {
text += fs.readFileSync(file, "utf8");
}
}
};
walk(path.join(dir, "src"));
return text;
}
function constants(text) {
const map = new Map();
for (const match of text.matchAll(
/const ([A-Z][A-Z0-9_]*)\s*=\s*"([^"]+)"/g,
)) {
map.set(match[1], match[2]);
}
return map;
}
function matches(patterns, text) {
const names = constants(text);
const found = new Set();
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const id = match[1] ?? names.get(match[2]);
if (id) found.add(id);
}
}
return found;
}
function collect(pluginsDir = PLUGINS) {
const plugins = fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) =>
fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")),
)
.map((entry) => {
const dir = path.join(pluginsDir, entry.name);
const manifest = JSON.parse(
fs.readFileSync(path.join(dir, "manifest.json"), "utf8"),
);
const source = sourceOf(dir);
return {
id: manifest.id,
declared: new Set(manifest.contributes?.uses ?? []),
owns: matches(OWNS, source),
uses: matches(USES, source),
};
});
const owner = new Map();
for (const plugin of plugins) {
for (const id of plugin.owns) if (!owner.has(id)) owner.set(id, plugin.id);
}
const problems = [];
const usage = {};
for (const plugin of plugins) {
for (const id of plugin.uses) {
const by = owner.get(id);
if (!by || by === plugin.id || plugin.owns.has(id)) continue;
(usage[plugin.id] ??= []).push(`${id} (${by})`);
if (!plugin.declared.has(id)) {
problems.push(
`${plugin.id} uses "${id}" from the ${by} plugin; add it to contributes.uses in plugins/${plugin.id}/manifest.json`,
);
}
}
for (const id of plugin.declared) {
if (!plugin.uses.has(id)) {
problems.push(
`${plugin.id} declares "${id}" in contributes.uses but never uses it`,
);
}
}
}
return { problems, usage, owner };
}
function main() {
const { problems, usage } = collect();
if (process.argv.includes("--list")) {
console.log(JSON.stringify(usage, null, 2));
return;
}
if (problems.length === 0) return;
for (const problem of problems) console.error(problem);
process.exit(1);
}
if (require.main === module) main();
/** Every action, slot and extension point id a plugin owns, by owner. */
function owners(pluginsDir = PLUGINS) {
return collect(pluginsDir).owner;
}
module.exports = { collect, owners };
-70
View File
@@ -1,70 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { collect } = require("./check-plugin-uses.cjs") as {
collect: (dir: string) => { problems: string[]; owner: Map<string, string> };
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function plugins(
specs: Record<string, { source: string; uses?: string[] }>,
): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-uses-"));
for (const [id, spec] of Object.entries(specs)) {
const dir = path.join(root, id);
fs.mkdirSync(path.join(dir, "src"), { recursive: true });
fs.writeFileSync(
path.join(dir, "manifest.json"),
JSON.stringify({ id, contributes: spec.uses ? { uses: spec.uses } : {} }),
);
fs.writeFileSync(path.join(dir, "src", "index.tsx"), spec.source);
}
return root;
}
describe("check-plugin-uses", () => {
it("finds owners through string literals and constants", () => {
const dir = plugins({
terminal: {
source:
'const SLOT = "terminal.toolbar";\napp.declareActionSlot({ id: SLOT });\napp.registerAction("terminal.open", f);\n',
},
});
const { owner } = collect(dir);
expect(owner.get("terminal.open")).toBe("terminal");
expect(owner.get("terminal.toolbar")).toBe("terminal");
});
it("requires another plugin's id in contributes.uses", () => {
const dir = plugins({
terminal: { source: 'app.registerAction("terminal.open", f);\n' },
files: { source: 'invokeAction("terminal.open", host);\n' },
});
expect(collect(dir).problems).toEqual([
expect.stringContaining('files uses "terminal.open" from the terminal'),
]);
});
it("passes a declared use and flags a declared one that is unused", () => {
const dir = plugins({
terminal: { source: 'app.registerAction("terminal.open", f);\n' },
files: {
source: 'invokeAction("terminal.open", host);\n',
uses: ["terminal.open", "terminal.gone"],
},
});
expect(collect(dir).problems).toEqual([
expect.stringContaining('declares "terminal.gone"'),
]);
});
});
+3 -8
View File
@@ -2,7 +2,7 @@
/**
* Tests mirror the source tree in their own directories and never sit next
* to the code they test. A *.test.ts(x) file may only live under
* src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/.
* src/backend/tests/, src/ui/tests/ or scripts/.
*/
const fs = require("node:fs");
@@ -20,12 +20,7 @@ const SKIP = new Set([
".turbo",
]);
const TEST_FILE = /\.test\.tsx?$/;
const ALLOWED = [
/^src\/backend\/tests\//,
/^src\/ui\/tests\//,
/^scripts\//,
/^plugins\/[^/]+\/tests\//,
];
const ALLOWED = [/^src\/backend\/tests\//, /^src\/ui\/tests\//, /^scripts\//];
function walk(dir, root, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -50,7 +45,7 @@ function main() {
const found = misplaced();
if (found.length === 0) return;
console.error(
"Tests belong in src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/, mirroring the source:",
"Tests belong in src/backend/tests/, src/ui/tests/ or scripts/, mirroring the source:",
);
for (const file of found) console.error(` ${file}`);
process.exit(1);
+2 -5
View File
@@ -32,9 +32,8 @@ describe("misplaced", () => {
"src/backend/tests/utils/a.test.ts",
"src/ui/tests/lib/b.test.tsx",
"scripts/c.test.ts",
"plugins/docker/tests/backend/d.test.ts",
"plugins/docker/node_modules/pkg/e.test.ts",
"plugins/docker/dist/f.test.ts",
"node_modules/pkg/e.test.ts",
"dist/f.test.ts",
]);
expect(misplaced(dir)).toEqual([]);
});
@@ -43,13 +42,11 @@ describe("misplaced", () => {
const dir = fixture([
"src/backend/utils/a.test.ts",
"src/ui/lib/b.test.tsx",
"plugins/docker/src/frontend/c.test.tsx",
"packages/plugin-sdk/src/d.test.ts",
"src/backend/utils/not-a-test.ts",
]);
expect(misplaced(dir)).toEqual([
"packages/plugin-sdk/src/d.test.ts",
"plugins/docker/src/frontend/c.test.tsx",
"src/backend/utils/a.test.ts",
"src/ui/lib/b.test.tsx",
]);
+1 -5
View File
@@ -52,12 +52,8 @@ async function resolveProjectId() {
return { id: match.id, targetLanguageIds: match.targetLanguageIds || [] };
}
// Core is /en.json and each plugin is /plugins/<id>/locales/en.json.
function isSourceFile(file) {
return (
file.path === `/${SOURCE_FILE}` ||
/^\/plugins\/[^/]+\/locales\/en\.json$/.test(file.path || "")
);
return file.path === `/${SOURCE_FILE}`;
}
async function resolveFileIds(projectId) {
+2 -6
View File
@@ -7,17 +7,13 @@ const { isSourceFile } = require("./crowdin-pretranslate.cjs") as {
};
describe("isSourceFile", () => {
it("matches the core and plugin source files", () => {
it("matches the core source file", () => {
expect(isSourceFile({ path: "/en.json" })).toBe(true);
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/en.json" })).toBe(
true,
);
});
it("skips anything else", () => {
expect(isSourceFile({ path: "/src/ui/locales/en.json" })).toBe(false);
expect(isSourceFile({ path: "/plugins/a/b/locales/en.json" })).toBe(false);
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/de.json" })).toBe(
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/en.json" })).toBe(
false,
);
expect(isSourceFile({})).toBe(false);
-40
View File
@@ -1,40 +0,0 @@
/**
* Installs the native dependencies bundled plugins declare. esbuild leaves a
* nativeDependencies package out of the plugin bundle, so the server's own
* node_modules has to have it. Run in the production image after npm ci.
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const plugins = path.resolve(root, process.argv[2] ?? "plugins");
const wanted = new Map();
for (const id of fs.existsSync(plugins) ? fs.readdirSync(plugins) : []) {
const manifestPath = path.join(plugins, id, "manifest.json");
const pkgPath = path.join(plugins, id, "package.json");
if (!fs.existsSync(manifestPath) || !fs.existsSync(pkgPath)) continue;
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
for (const name of manifest.nativeDependencies ?? []) {
const range = pkg.dependencies?.[name];
if (!range) {
console.error(`${id}: native dependency ${name} has no version`);
process.exit(1);
}
wanted.set(name, range);
}
}
if (wanted.size === 0) process.exit(0);
const specs = [...wanted].map(([name, range]) => `${name}@${range}`);
console.log(`Installing native plugin dependencies: ${specs.join(" ")}`);
const result = spawnSync(
"npm",
["install", "--no-save", "--omit=dev", "--no-audit", "--no-fund", ...specs],
{ cwd: root, stdio: "inherit", shell: process.platform === "win32" },
);
process.exit(result.status ?? 1);
+3 -38
View File
@@ -2,7 +2,6 @@
* Reads docker/bundled-plugins.json: which plugins ship in the image and
* where each comes from.
*
* { "id": "x", "source": "workspace" } built from plugins/x
* { "id": "x", "source": "tmxplug", "url": "...", "sha256": "..." }
* { "id": "x", "source": "tmxplug", "path": "...", "sha256": "..." }
*
@@ -17,7 +16,7 @@ const path = require("node:path");
const ID = /^[a-z0-9][a-z0-9-]*$/;
const SHA256 = /^[0-9a-f]{64}$/;
function parseBundledPlugins(raw, workspaceIds) {
function parseBundledPlugins(raw) {
const problems = [];
const list = raw && Array.isArray(raw.plugins) ? raw.plugins : null;
if (!list) {
@@ -41,15 +40,7 @@ function parseBundledPlugins(raw, workspaceIds) {
}
seen.add(entry.id);
if (entry.source === "workspace") {
if (!workspaceIds.includes(entry.id)) {
problems.push(
`${entry.id} is a workspace plugin but plugins/${entry.id} does not exist`,
);
continue;
}
plugins.push({ id: entry.id, source: "workspace" });
} else if (entry.source === "tmxplug") {
if (entry.source === "tmxplug") {
const hasUrl =
typeof entry.url === "string" && entry.url.startsWith("https://");
const hasPath = typeof entry.path === "string" && entry.path.length > 0;
@@ -63,12 +54,6 @@ function parseBundledPlugins(raw, workspaceIds) {
problems.push(`${entry.id} needs a lowercase hex sha256`);
continue;
}
if (workspaceIds.includes(entry.id)) {
problems.push(
`${entry.id} comes from a .tmxplug, so delete plugins/${entry.id}`,
);
continue;
}
plugins.push({
id: entry.id,
source: "tmxplug",
@@ -81,33 +66,13 @@ function parseBundledPlugins(raw, workspaceIds) {
}
}
for (const id of workspaceIds) {
if (!seen.has(id)) {
problems.push(
`plugins/${id} is not listed in docker/bundled-plugins.json`,
);
}
}
return { plugins, problems };
}
function loadBundledPlugins(root) {
const configPath = path.join(root, "docker", "bundled-plugins.json");
const pluginsDir = path.join(root, "plugins");
const workspaceIds = fs.existsSync(pluginsDir)
? fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) =>
fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")),
)
.map((entry) => entry.name)
.sort()
: [];
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
const { plugins, problems } = parseBundledPlugins(raw, workspaceIds);
const { plugins, problems } = parseBundledPlugins(raw);
if (problems.length > 0) {
throw new Error(`docker/bundled-plugins.json:\n ${problems.join("\n ")}`);
}
-124
View File
@@ -1,124 +0,0 @@
/**
* Every SQL statement a 2.8 install's Postgres or MySQL database runs on its
* way to 2.9.0, in the order it runs them: core's drizzle migrations added
* since release-2.8.0-tag, then each bundled plugin's own migrations in the
* order the loader activates them. The data copies that run after that
* (src/backend/upgrade/) are TypeScript, not SQL, and are covered by the
* upgrade test and scripts/upgrade-check.sh.
*
* Snapshot-tested by scripts/upgrade-sql.test.ts, so any change to the
* upgrade path shows up as a diff in review.
*
* Usage: node scripts/lib/upgrade-sql.mjs postgres|mysql
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
/** The last core migration release-2.8.0-tag shipped, per dialect. */
export const LAST_28_MIGRATION = {
postgres: "0024_flimsy_forge",
mysql: "0023_even_beast",
};
/** Plugin ids in the order the loader activates them: dependencies first. */
export function pluginActivationOrder(root = ROOT) {
const dir = path.join(root, "plugins");
const manifests = new Map(
fs
.readdirSync(dir, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(dir, entry.name, "manifest.json")),
)
.map((entry) => [
entry.name,
JSON.parse(
fs.readFileSync(path.join(dir, entry.name, "manifest.json"), "utf8"),
),
])
.sort(([a], [b]) => a.localeCompare(b)),
);
const order = [];
const done = new Set();
const visit = (id) => {
if (done.has(id) || !manifests.has(id)) return;
done.add(id);
const manifest = manifests.get(id);
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
visit(dependency);
}
for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) {
visit(dependency);
}
order.push(id);
};
for (const id of manifests.keys()) visit(id);
return order;
}
function section(title, sql) {
return `-- ==== ${title} ====\n${sql.trim()}\n`;
}
export function buildUpgradeSql(dialect, root = ROOT) {
if (!(dialect in LAST_28_MIGRATION)) {
throw new Error(`unsupported dialect "${dialect}"`);
}
const parts = [
`-- Termix 2.8.0 to 2.9.0 on ${dialect}, generated by scripts/lib/upgrade-sql.mjs\n`,
];
const coreDir = path.join(root, "drizzle", dialect);
const journal = JSON.parse(
fs.readFileSync(path.join(coreDir, "meta", "_journal.json"), "utf8"),
);
const tags = journal.entries.map((entry) => entry.tag);
const last = tags.indexOf(LAST_28_MIGRATION[dialect]);
if (last === -1) {
throw new Error(
`${LAST_28_MIGRATION[dialect]} is not in the ${dialect} journal`,
);
}
for (const tag of tags.slice(last + 1)) {
parts.push(
section(
`core ${tag}`,
fs.readFileSync(path.join(coreDir, `${tag}.sql`), "utf8"),
),
);
}
for (const id of pluginActivationOrder(root)) {
const dir = path.join(root, "plugins", id, "migrations", dialect);
if (!fs.existsSync(dir)) continue;
for (const file of fs
.readdirSync(dir)
.filter((name) => name.endsWith(".sql"))
.sort()) {
parts.push(
section(
`plugin ${id} ${file}`,
fs.readFileSync(path.join(dir, file), "utf8"),
),
);
}
}
return parts.join("\n").replace(/\r\n/g, "\n");
}
if (
process.argv[1] &&
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
) {
process.stdout.write(buildUpgradeSql(process.argv[2] ?? "postgres"));
}
-161
View File
@@ -1,161 +0,0 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
const GuacamoleLite = require("../node_modules/guacamole-lite/lib/Server.js");
type PatchedGuacdClient = {
connectionSettings: Record<string, unknown>;
nextArgumentStreamIndex: number;
sendInstruction: ReturnType<typeof vi.fn>;
sendHandshakeReply: (serverHandshake: string[]) => void;
sendRequiredArguments: (params: string[]) => void;
};
function createPatchedClient(
connectionSettings: Record<string, unknown>,
): PatchedGuacdClient {
return Object.assign(Object.create(GuacdClient.prototype), {
connectionSettings,
logger: { log: vi.fn() },
nextArgumentStreamIndex: 0,
sendInstruction: vi.fn(),
});
}
describe("patch-guacamole-lite", () => {
it("rejects a malformed token without starting or retaining a connection", async () => {
const server = Object.assign(Object.create(GuacamoleLite.prototype), {
connectionsCount: 0,
clientOptions: {
crypt: {
cypher: "AES-256-CBC",
key: Buffer.alloc(32, 7),
},
log: {
level: 0,
stdLog: vi.fn(),
errorLog: vi.fn(),
},
},
callbacks: {
processConnectionSettings: vi.fn(),
},
extractGuacdOptions: vi.fn(async () => ({
guacdOptions: { host: "127.0.0.1", port: 4822 },
connectionInfo: null,
isJoin: false,
targetSessionId: null,
})),
activeConnections: new Map(),
emit: vi.fn(),
});
const webSocket = {
OPEN: 1,
readyState: 1,
send: vi.fn(),
close: vi.fn(),
on: vi.fn(),
removeAllListeners: vi.fn(),
};
await server.newConnection(webSocket, {
url: "/guacamole/websocket/?token=not-an-encrypted-token",
});
expect(webSocket.close).toHaveBeenCalledOnce();
expect(server.activeConnections.size).toBe(0);
expect(server.emit).not.toHaveBeenCalledWith("open", expect.anything());
});
it("handles guacd dynamic argument requests", () => {
const guacdClientPath = path.join(
process.cwd(),
"node_modules",
"guacamole-lite",
"lib",
"GuacdClient.js",
);
const content = fs.readFileSync(guacdClientPath, "utf8");
expect(content).toContain("sendRequiredArguments(params)");
expect(content).toContain("opcode === 'required' || opcode === 'require'");
expect(content).toContain("this.sendInstruction(['argv'");
expect(content).toContain("this.sendInstruction(['blob'");
expect(content).toContain("this.sendInstruction(['end'");
});
it("keeps required-argument support when guacd offers a future 1.x protocol", () => {
const client = createPatchedClient({
hostname: "192.0.2.10",
port: 5900,
password: "secret",
width: 1280,
height: 720,
dpi: 96,
});
client.sendHandshakeReply(["VERSION_1_6_0", "hostname", "port"]);
expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"name",
"guacamole-lite",
]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"connect",
"VERSION_1_5_0",
"192.0.2.10",
5900,
]);
});
it("sends name instruction for VERSION_1_1_0 to fix guacd 1.6.0 VNC drops", () => {
const client = createPatchedClient({
hostname: "192.0.2.10",
port: 5900,
password: "secret",
width: 1280,
height: 720,
dpi: 96,
});
client.sendHandshakeReply(["VERSION_1_1_0", "hostname", "port"]);
expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"name",
"guacamole-lite",
]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"connect",
"VERSION_1_1_0",
"192.0.2.10",
5900,
]);
});
it("answers required credentials through argument value streams", () => {
const client = createPatchedClient({
username: "",
password: "secret",
});
client.sendRequiredArguments(["username", "password"]);
expect(
client.sendInstruction.mock.calls.map(([instruction]) => instruction),
).toEqual([
["argv", 0, "text/plain", "username"],
["blob", 0, ""],
["end", 0],
["argv", 1, "text/plain", "password"],
["blob", 1, Buffer.from("secret", "utf8").toString("base64")],
["end", 1],
]);
});
});
+2 -20
View File
@@ -24,13 +24,8 @@ function fixture() {
path.join(root, "node_modules"),
"dir",
);
fs.mkdirSync(path.join(root, "scripts"));
fs.copyFileSync(
path.join(repo, "scripts/apply-plugin-patches.cjs"),
path.join(root, "scripts/apply-plugin-patches.cjs"),
);
const plugin = path.join(root, "plugins/fixture");
fs.mkdirSync(path.join(plugin, "patches"), { recursive: true });
const plugin = path.join(root, "fixture");
fs.mkdirSync(plugin);
fs.writeFileSync(
path.join(plugin, "manifest.json"),
JSON.stringify({ id: "fixture" }),
@@ -39,10 +34,6 @@ function fixture() {
path.join(plugin, "package.json"),
JSON.stringify({ name: "fixture" }),
);
fs.writeFileSync(
path.join(plugin, "patches/check.cjs"),
'require("node:fs").writeFileSync("patched", "ok");',
);
const run = (...args: string[]) =>
spawnSync(process.execPath, args, { cwd: plugin, encoding: "utf8" });
return {
@@ -54,15 +45,6 @@ function fixture() {
}
describe("plugin CLI before the SDK is built", () => {
it("runs the postinstall plugin patches without SDK dist files", () => {
const { root, plugin, run } = fixture();
expect(fs.existsSync(path.join(root, "packages/plugin-sdk/dist"))).toBe(
false,
);
const result = run(path.join(root, "scripts/apply-plugin-patches.cjs"));
expect(result.status, result.stderr).toBe(0);
expect(fs.readFileSync(path.join(plugin, "patched"), "utf8")).toBe("ok");
});
it("prints help without SDK dist files", () => {
const { cli, run } = fixture();
const result = run(cli, "--help");
-85
View File
@@ -1,85 +0,0 @@
/**
* Runs every plugin's own vitest suite.
*
* Core's `npm run test` covers src/ only. A plugin's tests live beside it and
* run under its own config, so a plugin that moves out of this repo keeps a
* working suite.
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const source = path.join(root, "plugins");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
const pluginIds = fs
.readdirSync(source, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) =>
fs.existsSync(path.join(source, entry.name, "vitest.config.ts")),
)
.map((entry) => entry.name);
const failed = [];
const skipped = [];
let ran = 0;
// A vitest fork that dies mid-run (seen on Windows under memory pressure)
// fails the suite without any test failing. That is retried once; a real
// test failure is not.
const WORKER_CRASH = /Worker exited unexpectedly|Worker forks emitted error/;
function runSuite(pluginDir) {
const result = spawnSync(process.execPath, [cli, "test"], {
cwd: pluginDir,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
process.stdout.write(result.stdout ?? "");
process.stderr.write(result.stderr ?? "");
return result;
}
for (const id of pluginIds) {
const pluginDir = path.join(source, id);
const hasTests = ["tests/backend", "tests/frontend"].some((dir) => {
const full = path.join(pluginDir, dir);
return (
fs.existsSync(full) &&
fs
.readdirSync(full, { recursive: true })
.some(
(name) =>
String(name).endsWith(".test.ts") ||
String(name).endsWith(".test.tsx"),
)
);
});
if (!hasTests) {
skipped.push(id);
continue;
}
ran++;
let result = runSuite(pluginDir);
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
if (result.status !== 0 && WORKER_CRASH.test(output)) {
console.warn(`\n${id}: a test worker crashed, running the suite again`);
result = runSuite(pluginDir);
}
if (result.status !== 0) failed.push(id);
}
if (skipped.length > 0) {
console.warn(`\nNo tests found for: ${skipped.join(", ")}`);
}
if (failed.length > 0) {
console.error(`\nPlugin tests failed: ${failed.join(", ")}`);
process.exit(1);
}
console.log(`\nPlugin tests passed for ${ran} plugin(s).`);
-35
View File
@@ -1,35 +0,0 @@
# Throwaway Postgres and MySQL servers for scripts/upgrade-check.sh. Data
# lives in tmpfs, so every run starts from an empty server.
name: termix-upgrade-check
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: termix
POSTGRES_PASSWORD: termix
POSTGRES_DB: termix
ports:
- "55433:5432"
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U termix -d termix"]
interval: 2s
timeout: 5s
retries: 30
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: termix
MYSQL_DATABASE: termix
ports:
- "33307:3306"
tmpfs:
- /var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ptermix"]
interval: 2s
timeout: 5s
retries: 60
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# Upgrades the 2.8 fixture install to 2.9.0 on real Postgres and MySQL
# servers and runs the same checks the SQLite upgrade test runs.
#
# Needs Docker and a built tree (npm run build). Run from anywhere:
# bash scripts/upgrade-check.sh # both engines
# bash scripts/upgrade-check.sh postgres # one engine
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE=(docker compose -f "$ROOT/scripts/upgrade-check.compose.yml")
TAG="release-2.8.0-tag"
if [ "$#" -gt 0 ]; then
DIALECTS=("$@")
else
DIALECTS=(postgres mysql)
fi
cd "$ROOT"
if [ ! -f dist/plugins/snippets/dist/backend.js ]; then
echo "dist/plugins is missing. Run npm run build first." >&2
exit 1
fi
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG is missing. Run git fetch --tags first." >&2
exit 1
fi
LEGACY="$(mktemp -d)"
cleanup() {
"${COMPOSE[@]}" down -v >/dev/null 2>&1 || true
rm -rf "$LEGACY"
}
trap cleanup EXIT
# 2.8's own drizzle migrations build the 2.8 schema on each server.
git archive "$TAG" drizzle | tar -x -C "$LEGACY"
"${COMPOSE[@]}" up -d --wait
status=0
for dialect in "${DIALECTS[@]}"; do
case "$dialect" in
postgres) url="postgres://termix:termix@127.0.0.1:55433/termix" ;;
mysql) url="mysql://root:termix@127.0.0.1:33307/termix" ;;
*)
echo "Unknown dialect $dialect (postgres or mysql)" >&2
exit 2
;;
esac
echo
echo "== 2.8 to 2.9.0 on $dialect"
if ! TEST_DIALECT="$dialect" TEST_DATABASE_URL="$url" \
UPGRADE_LEGACY_MIGRATIONS_DIR="$LEGACY/drizzle" \
npx vitest run src/backend/tests/plugins/upgrade-fixture.test.ts \
--project backend; then
status=1
fi
done
exit "$status"
-44
View File
@@ -1,44 +0,0 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { LEGACY_TABLE_OWNERS } from "../packages/plugin-sdk/src/db";
import { buildUpgradeSql } from "./lib/upgrade-sql.mjs";
const SNAPSHOTS = path.resolve(
__dirname,
"../src/backend/tests/fixtures/upgrade",
);
const RENAME = {
postgres: /^ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)";$/gm,
mysql: /^RENAME TABLE `([a-z0-9_]+)` TO `(p_[a-z0-9_]+)`;$/gm,
};
describe.each(["postgres", "mysql"] as const)(
"the 2.8 to 2.9.0 upgrade SQL on %s",
(dialect) => {
const sql = buildUpgradeSql(dialect);
const statements = sql.replace(/^--.*$/gm, "");
it("matches the reviewed snapshot", async () => {
await expect(sql).toMatchFileSnapshot(
path.join(SNAPSHOTS, `upgrade-${dialect}.sql`),
);
});
it("adopts every legacy table by renaming it, never by dropping it", () => {
const renamed = new Set(
[...sql.matchAll(RENAME[dialect])].map((match) => match[1]),
);
for (const table of Object.keys(LEGACY_TABLE_OWNERS)) {
expect(renamed, table).toContain(table);
expect(statements).not.toMatch(
new RegExp(`DROP TABLE[^;]*[\`"]${table}[\`"]`, "i"),
);
}
});
it("never drops a column", () => {
expect(statements).not.toMatch(/DROP COLUMN/i);
});
},
);
-94
View File
@@ -1,94 +0,0 @@
/**
* Validates plugin manifests against the SDK contract.
*
* node scripts/validate-plugin-manifest.cjs # every bundled manifest
* node scripts/validate-plugin-manifest.cjs path/to/manifest.json
*
* The rules live in packages/plugin-sdk/src/manifest.ts, which the server
* uses too, so there is one implementation rather than a copy here that
* drifts. This file only finds the manifests and prints the result.
*/
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const root = path.resolve(__dirname, "..");
const sdkEntry = path.join(
root,
"packages",
"plugin-sdk",
"dist",
"manifest.js",
);
async function loadValidator() {
if (!fs.existsSync(sdkEntry)) {
console.error(
`The plugin SDK is not built yet (${path.relative(root, sdkEntry)} is missing).\n` +
`Run: npm run build:sdk`,
);
process.exit(1);
}
return import(pathToFileURL(sdkEntry).href);
}
function bundledManifests() {
const pluginsDir = path.join(root, "plugins");
if (!fs.existsSync(pluginsDir)) return [];
return fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(pluginsDir, entry.name, "manifest.json"))
.filter((file) => fs.existsSync(file));
}
async function main() {
const { parseManifest } = await loadValidator();
const argument = process.argv[2];
const files = argument
? [path.resolve(process.cwd(), argument)]
: bundledManifests();
if (files.length === 0) {
console.log("No plugin manifests found.");
return;
}
let failed = 0;
for (const file of files) {
const relative = path.relative(root, file);
let raw;
try {
raw = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
console.error(`${relative}: not valid JSON: ${error.message}`);
failed += 1;
continue;
}
const { errors } = parseManifest(raw);
if (errors.length > 0) {
console.error(`${relative}:`);
for (const error of errors) console.error(` - ${error}`);
failed += 1;
continue;
}
console.log(`ok ${relative}`);
}
if (failed > 0) {
console.error(`\n${failed} manifest(s) failed validation.`);
process.exit(1);
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
-97
View File
@@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import { execFileSync } from "node:child_process";
import path from "node:path";
const VALIDATOR = path.join(__dirname, "validate-plugin-manifest.cjs");
const FIXTURES = path.join(__dirname, "__fixtures__", "manifests");
function runValidator(fixture?: string): { status: number; output: string } {
const args = fixture
? [VALIDATOR, path.join(FIXTURES, fixture)]
: [VALIDATOR];
try {
const output = execFileSync("node", args, { encoding: "utf8" });
return { status: 0, output };
} catch (err) {
const error = err as { status: number; stdout: string; stderr: string };
return { status: error.status, output: error.stdout + error.stderr };
}
}
describe("validate-plugin-manifest.cjs", () => {
it("accepts a valid manifest", () => {
const { status, output } = runValidator("valid.json");
expect(status).toBe(0);
expect(output).toContain("ok");
});
it("accepts a manifest declaring services", () => {
expect(runValidator("valid-services.json").status).toBe(0);
});
it("accepts a manifest declaring actions", () => {
expect(runValidator("valid-actions.json").status).toBe(0);
});
it("rejects a manifest missing a required field", () => {
const { status, output } = runValidator("invalid-missing-field.json");
expect(status).toBe(1);
expect(output).toContain("id");
});
it("rejects an id that is not a lowercase slug", () => {
const { status, output } = runValidator("invalid-bad-id.json");
expect(status).toBe(1);
expect(output).toContain("id");
});
it("rejects an unknown category", () => {
expect(runValidator("invalid-unknown-category.json").status).toBe(1);
});
// The dotted spelling was the v1 form. Capabilities are colon-separated now
// so they can never be confused with RBAC permissions.
it("rejects a dotted capability name", () => {
const { status, output } = runValidator("invalid-unknown-capability.json");
expect(status).toBe(1);
expect(output).toContain("hosts.read");
});
it("rejects a service permission the plugin does not declare", () => {
expect(runValidator("invalid-service-permission.json").status).toBe(1);
});
it("rejects an action permission the plugin does not declare", () => {
expect(runValidator("invalid-action-permission.json").status).toBe(1);
});
it("rejects an unknown top-level field", () => {
const { status, output } = runValidator("invalid-unknown-field.json");
expect(status).toBe(1);
expect(output).toContain("sudoEverything");
});
it("rejects an unknown field nested inside contributes", () => {
const { status, output } = runValidator(
"invalid-nested-unknown-field.json",
);
expect(status).toBe(1);
expect(output).toContain("extra");
});
// A manifest must not be able to name a core namespace, which is how a
// plugin could otherwise gate a route on admin authority it never had.
it("rejects a permission name outside the plugin's own namespace", () => {
const { status, output } = runValidator(
"invalid-role-default-escalation.json",
);
expect(status).toBe(1);
expect(output).toContain("admin.users.manage");
});
it("validates every bundled manifest when given no argument", () => {
const { status, output } = runValidator();
expect(status).toBe(0);
expect(output).toContain("ssh-terminal");
});
});
+2 -27
View File
@@ -4,10 +4,8 @@
* - Every shared module (React, i18next and the SDK) becomes an entry chunk,
* reached through a stable shim at dist/shared/<name>.js.
* - index.html gets an import map pointing those bare specifiers at the shims,
* so a plugin bundle's `import "react"` lands on the shell's React.
* - In `vite dev`, src/ui/plugin-host/workspace-plugins.ts is replaced with
* import.meta.glob calls, so the monorepo's plugins load through Vite with
* HMR, and the map points at dev-server module URLs instead.
* so a plugin bundle's `import "react"` lands on the shell's React. In
* `vite dev` the map points at dev-server module URLs instead.
*/
import fs from "node:fs";
@@ -72,9 +70,6 @@ function sourceFacade(file) {
export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
let command = "build";
let shared = [];
const workspaceModule = path
.join(repoRoot, "src", "ui", "plugin-host", "workspace-plugins.ts")
.replaceAll("\\", "/");
const facadeSource = (specifier) => {
if (SHARED_VENDOR_MODULES.includes(specifier)) {
@@ -119,26 +114,6 @@ export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
if (id.startsWith(VIRTUAL_PREFIX)) {
return facadeSource(id.slice(VIRTUAL_PREFIX.length));
}
if (
command === "serve" &&
id.replaceAll("\\", "/").split("?")[0] === workspaceModule
) {
return [
'const frontends = import.meta.glob("/plugins/*/src/frontend/index.{tsx,ts}");',
'const locales = import.meta.glob("/plugins/*/locales/**/*.json", { import: "default" });',
"export const workspaceFrontends = {};",
"export const workspaceLocales = {};",
"for (const [file, load] of Object.entries(frontends)) {",
' workspaceFrontends[file.split("/")[2]] = load;',
"}",
"for (const [file, load] of Object.entries(locales)) {",
' const parts = file.split("/");',
" const id = parts[2];",
' const name = parts[parts.length - 1].replace(/\\.json$/, "");',
" (workspaceLocales[id] ??= {})[name] = load;",
"}",
].join("\n");
}
return null;
},