diff --git a/package-lock.json b/package-lock.json index a1023ee6d..e824a3242 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15015,7 +15015,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.1.0", + "version": "1.0.5", "license": "Apache-2.0", "dependencies": { "@deadendjs/swagger-jsdoc": "^8.1.2", @@ -15054,9 +15054,21 @@ "drizzle-orm": { "optional": true }, + "i18next": { + "optional": true + }, "react": { "optional": true }, + "react-dom": { + "optional": true + }, + "react-i18next": { + "optional": true + }, + "sonner": { + "optional": true + }, "ssh2": { "optional": true }, diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index dda5ef171..ab93f3fd0 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.5 + +### Fixed + +- `execCommand` timeout errors no longer include the command, which could hold a sudo password +- `termix-plugin validate` fails when a plugin ships migrations for some database engines but not all three +- `termix-plugin migrations` loads the table definitions from a path with a `#` or other URL characters in it +- `i18next`, `react-dom`, `react-i18next` and `sonner` are optional peer dependencies, so npm stops installing them into backend-only plugins + ## 1.0.4 ### Fixed diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index d8f259a03..1de961a53 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -28,6 +28,7 @@ npm install --save-dev @termix-ssh/plugin-sdk ```bash npx termix-plugin build # bundle dist/backend.js and dist/frontend.js +npx termix-plugin patch # apply the plugin's dependency patches (build does this too) npx termix-plugin test # run the plugin's Vitest suite npx termix-plugin validate # check manifest.json and the files it names npx termix-plugin openapi # write dist/openapi.json from @openapi comments diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index 23967aa86..690762389 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -108,13 +108,6 @@ const HOST_DIR = path.join( const THEME_CSS = path.join(HOST_DIR, "theme.css"); const CORE_CLASSES = path.join(HOST_DIR, "core-classes.json"); -/** - * 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). - */ /** * Moves a plugin's Tailwind layers under their own names. Core orders * termix-plugin-utilities above its base styles and below its own utilities, @@ -136,6 +129,13 @@ function readCoreClasses() { return new Set(JSON.parse(fs.readFileSync(CORE_CLASSES, "utf8"))); } +/** + * 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"); diff --git a/packages/plugin-sdk/cli/commands/migrations.mjs b/packages/plugin-sdk/cli/commands/migrations.mjs index 7114e5af3..22cdff5e5 100644 --- a/packages/plugin-sdk/cli/commands/migrations.mjs +++ b/packages/plugin-sdk/cli/commands/migrations.mjs @@ -17,7 +17,7 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import * as esbuild from "esbuild"; import { BACKEND_EXTERNALS } from "../lib/externals.mjs"; import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs"; @@ -53,7 +53,7 @@ async function loadDefinitions(cwd) { external: BACKEND_EXTERNALS, }); - const module = await import(`file://${outfile.split(path.sep).join("/")}`); + const module = await import(pathToFileURL(outfile).href); const tables = module.tables ?? module.default; if (!Array.isArray(tables)) { throw new Error( diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs index c728927c3..10b423f2a 100644 --- a/packages/plugin-sdk/cli/commands/validate.mjs +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -218,6 +218,11 @@ async function validateMigrations(cwd, pluginId) { const present = DIALECTS.filter((dialect) => fs.existsSync(path.join(root, dialect)), ); + for (const dialect of DIALECTS) { + if (present.length > 0 && !present.includes(dialect)) { + problems.push(`migrations/${dialect} is missing`); + } + } const byDialect = new Map(); for (const dialect of present) { diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 3bb1dc4a8..7a2a91553 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.4", + "version": "1.0.5", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { @@ -148,6 +148,18 @@ }, "@testing-library/react": { "optional": true + }, + "i18next": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-i18next": { + "optional": true + }, + "sonner": { + "optional": true } }, "publishConfig": { diff --git a/packages/plugin-sdk/src/host-commands.ts b/packages/plugin-sdk/src/host-commands.ts index 23df504dd..b526cf945 100644 --- a/packages/plugin-sdk/src/host-commands.ts +++ b/packages/plugin-sdk/src/host-commands.ts @@ -32,7 +32,7 @@ export function execCommand( if (!settled) { settled = true; cleanup(); - reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`)); + reject(new Error(`Command timeout after ${timeoutMs}ms`)); } }, timeoutMs); diff --git a/scripts/plugin-validate.test.ts b/scripts/plugin-validate.test.ts new file mode 100644 index 000000000..06b9602d1 --- /dev/null +++ b/scripts/plugin-validate.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { validate } from "../packages/plugin-sdk/cli/commands/validate.mjs"; + +const roots: string[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) + fs.rmSync(root, { recursive: true, force: true }); +}); + +function plugin(dialects: string[]) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-validate-")); + roots.push(root); + fs.writeFileSync( + path.join(root, "manifest.json"), + JSON.stringify({ id: "fixture", version: "1.0.0" }), + ); + for (const dialect of dialects) { + const dir = path.join(root, "migrations", dialect); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "0001_init.sql"), + "CREATE TABLE p_fixture_items (id INTEGER PRIMARY KEY);\n", + ); + } + return root; +} + +async function problems(cwd: string) { + const lines: string[] = []; + vi.spyOn(console, "error").mockImplementation((line) => lines.push(line)); + vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(validate({ cwd })).rejects.toThrow(); + return lines.join("\n"); +} + +describe("termix-plugin validate migrations", () => { + it("flags an engine with no migrations folder", async () => { + const output = await problems(plugin(["sqlite", "postgres"])); + expect(output).toContain("migrations/mysql is missing"); + expect(output).not.toContain("migrations/postgres is missing"); + }); + + it("accepts all three engines", async () => { + const output = await problems(plugin(["sqlite", "postgres", "mysql"])); + expect(output).not.toMatch(/migrations\/\w+ is missing/); + }); +}); diff --git a/src/backend/tests/plugins/host-commands.test.ts b/src/backend/tests/plugins/host-commands.test.ts index c8ed06f23..9d27a2783 100644 --- a/src/backend/tests/plugins/host-commands.test.ts +++ b/src/backend/tests/plugins/host-commands.test.ts @@ -6,6 +6,7 @@ import { isValidPackageName, buildSudoCommand, parseUpgradable, + execCommand, } from "@termix-ssh/plugin-sdk/host-commands"; describe("parsePlatformProbe", () => { @@ -114,3 +115,13 @@ describe("parseUpgradable", () => { expect(parseUpgradable(null, "anything")).toEqual([]); }); }); + +describe("execCommand", () => { + it("leaves the command out of the timeout error", async () => { + const client = { exec: () => {} } as never; + const command = buildSudoCommand("apt update", "hunter2"); + await expect(execCommand(client, command, 5)).rejects.toThrow( + /^Command timeout after 5ms$/, + ); + }); +});