feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

@@ -1,103 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { FleetRepository } from "../../../database/repositories/fleet-repository.js";
describe("FleetRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<{ repository: FleetRepository }> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type, tags)
VALUES
(1, 'user-1', 'web-1', '10.0.0.1', 22, 'root', 'password', 'prod-web,edge'),
(2, 'user-1', 'web-2', '10.0.0.2', 22, 'root', 'password', 'prod-web'),
(3, 'user-1', 'db-1', '10.0.0.3', 22, 'root', 'password', 'prod-db'),
(4, 'user-1', 'static-only', '10.0.0.4', 22, 'root', 'password', NULL),
(5, 'user-2', 'other-user-host', '10.0.0.5', 22, 'root', 'password', 'prod-web');
`);
return { repository: new FleetRepository(context, onWrite) };
}
it("unions static membership and tag-matched hosts, deduplicated by id", async () => {
let writes = 0;
const { repository } = await createRepository(() => {
writes += 1;
});
const fleet = await repository.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
// host 1 matches by tag AND is added statically - must appear once.
await repository.addMember(fleet.id, 1);
// host 4 has no tags - only reachable via static membership.
await repository.addMember(fleet.id, 4);
const members = await repository.listEffectiveMembers("user-1", fleet.id);
const ids = members.map((m) => m.id).sort((a, b) => a - b);
expect(ids).toEqual([1, 2, 4]);
expect(writes).toBeGreaterThan(0);
});
it("never returns another user's hosts even if tags match", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
const members = await repository.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).not.toContain(5);
});
it("returns only static members when a fleet has no tag rules", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", { name: "static fleet" });
await repository.addMember(fleet.id, 3);
const members = await repository.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toEqual([3]);
});
it("removeMember only drops the static row, not tag-based membership", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
await repository.addMember(fleet.id, 1);
await expect(repository.removeMember(fleet.id, 1)).resolves.toBe(true);
// host 1 still matches the tag rule, so it remains an effective member.
const members = await repository.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toContain(1);
});
it("returns an empty list for a fleet the caller does not own", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", { name: "private" });
await expect(
repository.listEffectiveMembers("user-2", fleet.id),
).resolves.toEqual([]);
});
});
@@ -1,80 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HostMetricsHistoryRepository } from "../../../database/repositories/host-metrics-history-repository.js";
describe("HostMetricsHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HostMetricsHistoryRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'user-1', 'hash'),
('user-2', 'user-2', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password');
INSERT INTO host_metrics_history (
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
)
VALUES
(1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200),
(1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201),
(1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202),
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
`);
return new HostMetricsHistoryRepository(context, onWrite);
}
it("creates and lists metrics history rows by range", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
hostId: 1,
cpuPercent: 12,
memPercent: 22,
diskPercent: 32,
netRxBytes: 102,
netTxBytes: 202,
});
const rows = await repo.listRange(
1,
"2026-01-01 00:00:00",
"2026-01-02 23:59:59",
);
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
expect(writeCount).toBe(1);
});
it("prunes old history for a host only", async () => {
const repo = await createRepository();
await repo.pruneOlderThan(1, 1);
const rows = await repo.listRange(
1,
"2000-01-01 00:00:00",
"2999-12-31 23:59:59",
);
expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]);
expect(
await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"),
).toHaveLength(1);
});
});
@@ -1,119 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HostMetricsPreferenceRepository } from "../../../database/repositories/host-metrics-preference-repository.js";
describe("HostMetricsPreferenceRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HostMetricsPreferenceRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, stats_config, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '{}', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '{}', '10.0.0.1', 22, 'root', 'password');
INSERT INTO host_metrics_preferences (
user_id, host_id, layout, created_at, updated_at
)
VALUES (
'user-1',
1,
'{"slots":[],"columns":3}',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z'
);
`);
return new HostMetricsPreferenceRepository(context, onWrite);
}
it("finds a saved layout by user and host", async () => {
const repo = await createRepository();
const existing = await repo.findByUserAndHost("user-1", 1);
expect(existing?.layout).toBe('{"slots":[],"columns":3}');
expect(await repo.findByUserAndHost("user-1", 2)).toBeNull();
});
it("updates and inserts layouts with write notifications", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.upsertLayout(
"user-1",
1,
'{"slots":[{"id":"cpu"}],"columns":2}',
"2026-02-01T00:00:00.000Z",
);
expect(updated).toMatchObject({
id: 1,
layout: '{"slots":[{"id":"cpu"}],"columns":2}',
updatedAt: "2026-02-01T00:00:00.000Z",
});
const created = await repo.upsertLayout(
"user-2",
2,
'{"slots":[{"id":"mem"}],"columns":1}',
"2026-03-01T00:00:00.000Z",
);
expect(created).toMatchObject({
userId: "user-2",
hostId: 2,
layout: '{"slots":[{"id":"mem"}],"columns":1}',
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
expect(writeCount).toBe(2);
});
it("updates host stats config for the owning user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(
repo.updateHostStatsConfig(
"user-1",
1,
'{"enabledWidgets":["cpu","memory"]}',
),
).resolves.toBe(true);
await expect(
repo.updateHostStatsConfig("user-2", 1, '{"enabledWidgets":["disk"]}'),
).resolves.toBe(false);
expect(writeCount).toBe(1);
});
it("deletes all metric preferences for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertLayout("user-2", 2, '{"slots":["mem"]}');
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
expect((await repo.findByUserAndHost("user-2", 2))?.layout).toBe(
'{"slots":["mem"]}',
);
expect(writeCount).toBe(2);
});
});
@@ -1,73 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { NetworkTopologyRepository } from "../../../database/repositories/network-topology-repository.js";
describe("NetworkTopologyRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<NetworkTopologyRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new NetworkTopologyRepository(context, onWrite);
}
it("finds, creates, and updates topology by user id", async () => {
const repo = await createRepository();
expect(await repo.findByUserId("user-1")).toBeNull();
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
"2026-06-27T00:00:00.000Z",
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[{"id":"host-1"}],"edges":[]}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [], edges: [{ id: "edge-1" }] }),
"2026-06-27T01:00:00.000Z",
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[],"edges":[{"id":"edge-1"}]}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
});
it("deletes topology and only triggers writes for changed rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", "{}");
await repo.upsertForUser("user-1", '{"nodes":[]}');
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(3);
expect(await repo.findByUserId("user-1")).toBeNull();
});
});
@@ -1,80 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { ProxmoxNodeHistoryRepository } from "../../../database/repositories/proxmox-node-history-repository.js";
describe("ProxmoxNodeHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<ProxmoxNodeHistoryRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'user-1', 'hash'),
('user-2', 'user-2', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'pve1', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'pve2', '10.0.0.2', 22, 'root', 'password');
INSERT INTO proxmox_node_history (
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
)
VALUES
(1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200),
(1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201),
(1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202),
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
`);
return new ProxmoxNodeHistoryRepository(context, onWrite);
}
it("creates and lists node history rows by range", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
hostId: 1,
cpuPercent: 12,
memPercent: 22,
diskPercent: 32,
netRxBytes: 102,
netTxBytes: 202,
});
const rows = await repo.listRange(
1,
"2026-01-01 00:00:00",
"2026-01-02 23:59:59",
);
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
expect(writeCount).toBe(1);
});
it("prunes old history for a host only", async () => {
const repo = await createRepository();
await repo.pruneOlderThan(1, 1);
const rows = await repo.listRange(
1,
"2000-01-01 00:00:00",
"2999-12-31 23:59:59",
);
expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]);
expect(
await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"),
).toHaveLength(1);
});
});
@@ -10,6 +10,7 @@ import {
} from "drizzle-orm";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "node:url";
import * as schema from "../../../database/db/schema.js";
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
import type { DatabaseDialect } from "../../../database/db/dialect.js";
@@ -448,7 +449,10 @@ let cachedSqliteSchema: string | null = null;
function sqliteSchemaSql(): string {
if (cachedSqliteSchema) return cachedSqliteSchema;
const dir = path.resolve(process.cwd(), "drizzle", "sqlite");
// Resolved from this file, not cwd: plugin suites run with their own
// directory as cwd and would not find drizzle/ otherwise.
const here = path.dirname(fileURLToPath(import.meta.url));
const dir = path.resolve(here, "../../../../..", "drizzle", "sqlite");
const files = fs
.readdirSync(dir)
.filter((name) => name.endsWith(".sql"))
@@ -1,204 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { WorkspaceRepository } from "../../../database/repositories/workspace-repository.js";
describe("WorkspaceRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<WorkspaceRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new WorkspaceRepository(context, onWrite);
}
it("creates and lists manual workspaces scoped to the owning user", async () => {
const repo = await createRepository();
await repo.create("user-1", { name: "Prod Debugging", payload: "{}" });
await repo.create("user-2", { name: "Other user's", payload: "{}" });
const list = await repo.listByUser("user-1");
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({
userId: "user-1",
name: "Prod Debugging",
kind: "manual",
isDefault: false,
});
expect(list[0].syncId).toBeTruthy();
});
it("finds a workspace by id scoped to the owner", async () => {
const repo = await createRepository();
const created = await repo.create("user-1", {
name: "Test A",
payload: "{}",
});
expect(await repo.findById("user-1", created.id)).toMatchObject({
id: created.id,
});
expect(await repo.findById("user-2", created.id)).toBeNull();
});
it("upsertLastSession creates then updates a single row, never a second one", async () => {
const repo = await createRepository();
const first = await repo.upsertLastSession("user-1", '{"tabs":[]}');
expect(first.kind).toBe("last_session");
const second = await repo.upsertLastSession(
"user-1",
'{"tabs":[{"slotId":"a"}]}',
);
expect(second.id).toBe(first.id);
expect(second.payload).toBe('{"tabs":[{"slotId":"a"}]}');
const all = await repo.listByUser("user-1");
expect(all.filter((w) => w.kind === "last_session")).toHaveLength(1);
});
it("update renames/recolors a manual workspace but rejects last_session", async () => {
const repo = await createRepository();
const manual = await repo.create("user-1", {
name: "Old Name",
payload: "{}",
});
const lastSession = await repo.upsertLastSession("user-1", "{}");
const updated = await repo.update("user-1", manual.id, {
name: "New Name",
color: "#fff",
});
expect(updated).toMatchObject({ name: "New Name", color: "#fff" });
const rejected = await repo.update("user-1", lastSession.id, {
name: "Should not work",
});
expect(rejected).toBeNull();
});
it("updateContent overwrites payload for a manual workspace but rejects last_session", async () => {
const repo = await createRepository();
const manual = await repo.create("user-1", {
name: "Test A",
payload: "{}",
});
const lastSession = await repo.upsertLastSession("user-1", "{}");
const updated = await repo.updateContent(
"user-1",
manual.id,
'{"tabs":[1]}',
);
expect(updated?.payload).toBe('{"tabs":[1]}');
const rejected = await repo.updateContent(
"user-1",
lastSession.id,
'{"tabs":[2]}',
);
expect(rejected).toBeNull();
});
it("setDefault clears any prior default and rejects last_session", async () => {
const repo = await createRepository();
const a = await repo.create("user-1", { name: "A", payload: "{}" });
const b = await repo.create("user-1", { name: "B", payload: "{}" });
const lastSession = await repo.upsertLastSession("user-1", "{}");
await repo.setDefault("user-1", a.id);
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
await repo.setDefault("user-1", b.id);
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
expect((await repo.findById("user-1", b.id))?.isDefault).toBe(true);
const rejected = await repo.setDefault("user-1", lastSession.id);
expect(rejected).toBeNull();
});
it("unsetDefault clears isDefault on a manual workspace and rejects last_session", async () => {
const repo = await createRepository();
const a = await repo.create("user-1", { name: "A", payload: "{}" });
const lastSession = await repo.upsertLastSession("user-1", "{}");
await repo.setDefault("user-1", a.id);
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
await repo.unsetDefault("user-1", a.id);
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
const rejected = await repo.unsetDefault("user-1", lastSession.id);
expect(rejected).toBeNull();
});
it("touchLastUsed sets lastUsedAt", async () => {
const repo = await createRepository();
const workspace = await repo.create("user-1", {
name: "A",
payload: "{}",
});
expect(workspace.lastUsedAt).toBeNull();
await repo.touchLastUsed(
"user-1",
workspace.id,
"2026-08-11T00:00:00.000Z",
);
expect((await repo.findById("user-1", workspace.id))?.lastUsedAt).toBe(
"2026-08-11T00:00:00.000Z",
);
});
it("delete removes a manual workspace but rejects last_session", async () => {
const repo = await createRepository();
const manual = await repo.create("user-1", { name: "A", payload: "{}" });
const lastSession = await repo.upsertLastSession("user-1", "{}");
await expect(repo.delete("user-1", lastSession.id)).resolves.toBe(false);
await expect(repo.delete("user-1", manual.id)).resolves.toBe(true);
expect(await repo.findById("user-1", manual.id)).toBeNull();
});
it("triggers writes on create/update/delete", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const created = await repo.create("user-1", {
name: "A",
payload: "{}",
});
await repo.update("user-1", created.id, { name: "B" });
await repo.delete("user-1", created.id);
expect(writeCount).toBe(3);
});
it("deleteByUserId removes every workspace owned by the user", async () => {
const repo = await createRepository();
await repo.create("user-1", { name: "A", payload: "{}" });
await repo.create("user-1", { name: "B", payload: "{}" });
await repo.create("user-2", { name: "C", payload: "{}" });
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
expect(await repo.listByUser("user-1")).toHaveLength(0);
expect(await repo.listByUser("user-2")).toHaveLength(1);
});
});