mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
@@ -48,11 +50,110 @@ export function externalRequireInterop(externals) {
|
||||
};
|
||||
}
|
||||
|
||||
const SDK_PATCHES = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"patches",
|
||||
);
|
||||
|
||||
/**
|
||||
* Patches a dependency before it is bundled, so the fix ships inside the
|
||||
* plugin rather than depending on whoever ran npm install.
|
||||
*
|
||||
* package.json "termix": { "patches": ["xterm-android-ime"] } names patches
|
||||
* the SDK ships for libraries several plugins share. Any .cjs file in the
|
||||
* plugin's own patches/ folder runs too. Every patch must be safe to run
|
||||
* twice.
|
||||
*/
|
||||
export function applyPatches(cwd, pluginId) {
|
||||
const pkgPath = path.join(cwd, "package.json");
|
||||
const pkg = fs.existsSync(pkgPath)
|
||||
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
|
||||
: {};
|
||||
const scripts = [];
|
||||
for (const name of pkg.termix?.patches ?? []) {
|
||||
const file = path.join(SDK_PATCHES, `${name}.cjs`);
|
||||
if (!/^[a-z0-9-]+$/.test(name) || !fs.existsSync(file)) {
|
||||
throw new Error(`${pluginId}: the SDK has no patch named "${name}"`);
|
||||
}
|
||||
scripts.push(file);
|
||||
}
|
||||
const own = path.join(cwd, "patches");
|
||||
if (fs.existsSync(own)) {
|
||||
for (const file of fs.readdirSync(own).sort()) {
|
||||
if (file.endsWith(".cjs")) scripts.push(path.join(own, file));
|
||||
}
|
||||
}
|
||||
for (const file of scripts) {
|
||||
const result = spawnSync(process.execPath, [file], {
|
||||
cwd,
|
||||
env: { ...process.env, TERMIX_PATCH_ROOT: cwd },
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${pluginId}: patch ${path.basename(file)} failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const THEME_CSS = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"dist",
|
||||
"host",
|
||||
"theme.css",
|
||||
);
|
||||
|
||||
/**
|
||||
* Compiles the Tailwind classes the plugin's frontend uses into
|
||||
* dist/frontend.css, against the theme core ships in the SDK. Core only
|
||||
* compiles its own classes, so a class no core file happens to use would
|
||||
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
|
||||
* produced (a library's stylesheet the plugin imports).
|
||||
*/
|
||||
export async function buildTailwind(cwd, outDir) {
|
||||
const { compile, optimize } = await import("@tailwindcss/node");
|
||||
const { Scanner } = await import("@tailwindcss/oxide");
|
||||
if (!fs.existsSync(THEME_CSS)) {
|
||||
throw new Error("The SDK theme is missing. Run: npm run build:sdk");
|
||||
}
|
||||
const input = [
|
||||
'@import "tailwindcss/theme.css" theme(reference);',
|
||||
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
|
||||
'@import "tailwindcss/utilities.css" layer(utilities);',
|
||||
].join("\n");
|
||||
const compiler = await compile(input, {
|
||||
base: path.dirname(THEME_CSS),
|
||||
onDependency: () => {},
|
||||
});
|
||||
const scanner = new Scanner({
|
||||
sources: [
|
||||
{
|
||||
base: path.join(cwd, "src", "frontend"),
|
||||
pattern: "**/*",
|
||||
negated: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||
// on native CSS nesting.
|
||||
const css = optimize(compiler.build(scanner.scan()), {
|
||||
minify: true,
|
||||
}).code.trim();
|
||||
if (!css) return;
|
||||
const target = path.join(outDir, "frontend.css");
|
||||
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
|
||||
fs.writeFileSync(target, `${existing}${existing ? "\n" : ""}${css}\n`);
|
||||
}
|
||||
|
||||
export async function build({ cwd }) {
|
||||
const manifest = readManifest(cwd);
|
||||
const pluginId = manifest.id ?? path.basename(cwd);
|
||||
const outDir = path.join(cwd, "dist");
|
||||
|
||||
applyPatches(cwd, pluginId);
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
@@ -123,6 +224,8 @@ export async function build({ cwd }) {
|
||||
});
|
||||
}
|
||||
|
||||
if (frontendEntry) await buildTailwind(cwd, outDir);
|
||||
|
||||
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
|
||||
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { LEGACY_TABLE_OWNERS as LEGACY_TABLES } from "../../dist/db.js";
|
||||
import { findUnownedTableWrites } from "../../dist/ddl.js";
|
||||
import { collectOwnedIndexes, findUnownedTableWrites } from "../../dist/ddl.js";
|
||||
|
||||
/**
|
||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||
@@ -39,6 +39,7 @@ export async function validate({ cwd }) {
|
||||
|
||||
problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
|
||||
problems.push(...validateNativeDependencies(cwd, raw));
|
||||
problems.push(...validatePackage(cwd, raw));
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
@@ -48,6 +49,36 @@ export async function validate({ cwd }) {
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* package.json has to agree with the manifest: the same version, and a real
|
||||
* SDK range rather than "*", so a plugin built against a newer SDK says so.
|
||||
*/
|
||||
function validatePackage(cwd, raw) {
|
||||
const problems = [];
|
||||
const pkgPath = path.join(cwd, "package.json");
|
||||
if (!fs.existsSync(pkgPath)) return problems;
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||
|
||||
if (pkg.version && raw.version && pkg.version !== raw.version) {
|
||||
problems.push(
|
||||
`package.json version ${pkg.version} does not match manifest version ${raw.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sdkRange =
|
||||
pkg.peerDependencies?.["@termix/plugin-sdk"] ??
|
||||
pkg.dependencies?.["@termix/plugin-sdk"] ??
|
||||
pkg.devDependencies?.["@termix/plugin-sdk"];
|
||||
if (!sdkRange) {
|
||||
problems.push("package.json does not depend on @termix/plugin-sdk");
|
||||
} else if (sdkRange === "*" || sdkRange === "latest") {
|
||||
problems.push(
|
||||
`@termix/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`,
|
||||
);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A native dependency has to actually be a real dependency of the plugin, or
|
||||
* the build's external declaration points at nothing node_modules can
|
||||
@@ -106,16 +137,25 @@ function validateMigrations(cwd, pluginId) {
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.sort();
|
||||
byDialect.set(dialect, files);
|
||||
const sqls = files.map((file) =>
|
||||
fs.readFileSync(path.join(dir, file), "utf8"),
|
||||
);
|
||||
const indexes = collectOwnedIndexes(pluginId, sqls, legacy);
|
||||
|
||||
for (const file of files) {
|
||||
for (const [index, file] of files.entries()) {
|
||||
if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) {
|
||||
problems.push(
|
||||
`migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`,
|
||||
);
|
||||
}
|
||||
|
||||
const sql = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
for (const problem of findUnownedTableWrites(pluginId, sql, legacy)) {
|
||||
const sql = sqls[index];
|
||||
for (const problem of findUnownedTableWrites(
|
||||
pluginId,
|
||||
sql,
|
||||
legacy,
|
||||
indexes,
|
||||
)) {
|
||||
problems.push(`migrations/${dialect}/${file} ${problem}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user