mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
chore: move plugins to their own repos and drop the plugins folder
This commit is contained in:
1 parent
29a946d02e
commit
e02b5fe6e4
3167 files changed
+286
-440749
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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`);
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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"'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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 ")}`);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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).`);
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user