feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

This commit is contained in:
LukeGus committed 2026-10-08 00:34:50 -05:00
1 parent 7fcda3bb01
commit cfab6f764c
65 files changed
+1665 -294

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Fails when core reads an env var that env.catalog.json does not list. The
// docs site builds its env var page from that file.
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const DIRS = ["src/backend", "electron"];
const PATTERNS = [
/process\.env\.([A-Z][A-Z0-9_]*)/g,
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
/_ENV\s*=\s*["']([A-Z][A-Z0-9_]*)["']/g,
];
function walk(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === "tests") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (
/\.(ts|cjs|mjs|js)$/.test(entry.name) &&
!/\.test\./.test(entry.name)
)
out.push(full);
}
return out;
}
function readNames(root = ROOT) {
const found = new Map();
for (const dir of DIRS) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) continue;
for (const file of walk(abs)) {
const text = fs.readFileSync(file, "utf8");
for (const pattern of PATTERNS) {
for (const match of text.matchAll(pattern)) {
if (!found.has(match[1]))
found.set(match[1], path.relative(root, file));
}
}
}
}
return found;
}
function main() {
const catalog = JSON.parse(
fs.readFileSync(path.join(ROOT, "env.catalog.json"), "utf8"),
);
const listed = new Set(catalog.vars.map((v) => v.name));
const problems = [];
for (const v of catalog.vars) {
if (!catalog.groups[v.group])
problems.push(`${v.name}: unknown group "${v.group}"`);
if (!v.description?.trim()) problems.push(`${v.name}: no description`);
}
for (const [name, file] of readNames()) {
if (!listed.has(name))
problems.push(
`${name} is read in ${file} but missing from env.catalog.json`,
);
}
if (problems.length) {
for (const p of problems) console.error(` ${p}`);
process.exit(1);
}
console.log(`env catalog ok (${listed.size} vars)`);
}
if (require.main === module) main();
module.exports = { readNames, PATTERNS };
+56
View File
@@ -0,0 +1,56 @@
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 { readNames } = require("./check-env-catalog.cjs") as {
readNames: (root: string) => Map<string, string>;
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "env-catalog-"));
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);
}
return root;
}
describe("readNames", () => {
it("finds every way core reads an env var", () => {
const dir = fixture({
"src/backend/a.ts": [
"const a = process.env.FIRST_VAR;",
'const b = process.env["SECOND_VAR"];',
"const c = env.THIRD_VAR;",
'export const X_ENV =\n "FOURTH_VAR";',
].join("\n"),
"electron/main.cjs": "process.env.DESKTOP_VAR",
});
expect([...readNames(dir).keys()].sort()).toEqual([
"DESKTOP_VAR",
"FIRST_VAR",
"FOURTH_VAR",
"SECOND_VAR",
"THIRD_VAR",
]);
});
it("skips tests", () => {
const dir = fixture({
"src/backend/tests/a.test.ts": "process.env.ONLY_IN_TESTS",
"src/backend/b.test.ts": "process.env.ALSO_TESTS",
});
expect(readNames(dir).size).toBe(0);
});
});
+2
View File
@@ -79,6 +79,8 @@ const ALLOWED = [
"DialogPortal",
"DialogTitle",
"DialogTrigger",
"DocsLink",
"DocsLinkProps",
"DropdownMenu",
"DropdownMenuCheckboxItem",
"DropdownMenuContent",
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
// Writes openapi.json for core from the @openapi blocks in src/backend.
// The docs site runs the same builder on each release.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildOpenApi } from "@termix-ssh/plugin-sdk/openapi";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pkg = JSON.parse(
fs.readFileSync(path.join(root, "package.json"), "utf8"),
);
const tags = JSON.parse(
fs.readFileSync(
path.join(root, "src/backend/utils/openapi-tags.json"),
"utf8",
),
);
const out = process.argv[2] ?? path.join(root, "openapi.json");
const spec = await buildOpenApi({
title: "Termix API",
version: pkg.version,
description: "The core Termix API. Each plugin documents its own routes.",
files: [path.join(root, "src/backend/**/!(*.test).ts")],
tags,
});
fs.writeFileSync(out, `${JSON.stringify(spec, null, 2)}\n`);
console.log(
`wrote ${path.relative(root, out)} (${Object.keys(spec.paths ?? {}).length} paths)`,
);