diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs
index 60591114c..df9fd1ecc 100644
--- a/packages/plugin-sdk/cli/commands/validate.mjs
+++ b/packages/plugin-sdk/cli/commands/validate.mjs
@@ -2,8 +2,6 @@ import fs from "node:fs";
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 { collectOwnedIndexes, findUnownedTableWrites } from "../../dist/ddl.js";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
@@ -37,7 +35,9 @@ export async function validate({ cwd }) {
}
}
- problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
+ problems.push(
+ ...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
+ );
problems.push(...validateNativeDependencies(cwd, raw));
problems.push(...validatePackage(cwd, raw));
@@ -115,7 +115,12 @@ const DIALECTS = ["sqlite", "postgres", "mysql"];
* never ran this command. Here it is a build-time error with a file name
* attached, rather than a plugin that fails to activate later.
*/
-function validateMigrations(cwd, pluginId) {
+async function validateMigrations(cwd, pluginId) {
+ const { LEGACY_TABLE_OWNERS: LEGACY_TABLES } =
+ await import("../../dist/db.js");
+ const { collectOwnedIndexes, findUnownedTableWrites } =
+ await import("../../dist/ddl.js");
+
const problems = [];
const legacy = new Set(
Object.entries(LEGACY_TABLES)
diff --git a/scripts/plugin-cli-bootstrap.test.ts b/scripts/plugin-cli-bootstrap.test.ts
new file mode 100644
index 000000000..98cd62916
--- /dev/null
+++ b/scripts/plugin-cli-bootstrap.test.ts
@@ -0,0 +1,79 @@
+import { afterEach, describe, expect, it } from "vitest";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+
+const roots: string[] = [];
+afterEach(() => {
+ for (const root of roots.splice(0))
+ fs.rmSync(root, { recursive: true, force: true });
+});
+
+function fixture() {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cli-bootstrap-"));
+ roots.push(root);
+ const repo = path.resolve(import.meta.dirname, "..");
+ fs.cpSync(
+ path.join(repo, "packages/plugin-sdk/cli"),
+ path.join(root, "packages/plugin-sdk/cli"),
+ { recursive: true },
+ );
+ fs.symlinkSync(
+ path.join(repo, "node_modules"),
+ 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 });
+ fs.writeFileSync(
+ path.join(plugin, "manifest.json"),
+ JSON.stringify({ id: "fixture" }),
+ );
+ fs.writeFileSync(
+ 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 {
+ root,
+ plugin,
+ run,
+ cli: path.join(root, "packages/plugin-sdk/cli/index.mjs"),
+ };
+}
+
+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");
+ expect(result.status, result.stderr).toBe(0);
+ expect(result.stdout).toContain("Usage: termix-plugin");
+ });
+ it("still requires a built SDK for validation with an actionable error", () => {
+ const { cli, run } = fixture();
+ const result = run(cli, "validate");
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain("Run: npm run build:sdk");
+ expect(result.stderr).not.toContain("ERR_MODULE_NOT_FOUND");
+ });
+});
diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx
index 7e0b1a5bf..9d4db9d20 100644
--- a/src/ui/sidebar/tree/HostItem/HostItem.tsx
+++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx
@@ -1167,18 +1167,11 @@ export function HostItem({
)}
- {isCompact &&
- !selectionMode &&
- !shouldUseClickTray &&
- !actionsOnly && (
-
- {host.ip}
-
- )}
- {isCompact && selectionMode && (
-
+ {isCompact && (
+
{host.ip}
)}
diff --git a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx
index b6f651570..30a4c6b79 100644
--- a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx
+++ b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx
@@ -187,6 +187,30 @@ describe("HostItem density parity", () => {
},
);
+ it.each(["hover", "click", "always", "actionsOnly"] as const)(
+ "keeps the compact host address visible with %s actions",
+ (trayTrigger) => {
+ const props = {
+ host: baseHost,
+ onOpenTab: noop,
+ onDelete: noop,
+ onDuplicate: noop,
+ density: "compact" as const,
+ trayTrigger,
+ };
+ const { rerender } = render();
+ expect(screen.getByText(baseHost.ip).classList.contains("hidden")).toBe(
+ false,
+ );
+ rerender();
+ expect(screen.getByText(baseHost.ip).classList.contains("hidden")).toBe(
+ false,
+ );
+ rerender();
+ expect(screen.getAllByText(baseHost.ip)).toHaveLength(1);
+ },
+ );
+
it("shows tags in both densities when showTags is true", () => {
renderHostItem("comfortable");
expect(screen.getByText("prod")).toBeTruthy();