mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: plugin guide for AI tools in the SDK with a lint check
This commit is contained in:
1 parent
6a7afa94a0
commit
c7596d72fb
7 files changed
+1020
-3
No files matched your search
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* packages/plugin-sdk/PLUGIN-GUIDE.md ships with the SDK and is what plugin
|
||||
* authors and their AI tools read first. Fails when it misses a CLI command,
|
||||
* a capability or an entry point, or when its header names an older SDK or
|
||||
* plugin API version, so the guide is touched with every SDK release.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SDK = path.join(ROOT, "packages", "plugin-sdk");
|
||||
|
||||
// Exports that are plumbing, not something a plugin author imports.
|
||||
const INTERNAL_EXPORTS = new Set([
|
||||
"./testing/setup",
|
||||
"./testing-host",
|
||||
"./tsconfig.plugin.json",
|
||||
"./schema/manifest.schema.json",
|
||||
"./theme.css",
|
||||
]);
|
||||
|
||||
function cliCommands(source) {
|
||||
const block = source.match(/const COMMANDS = \{([\s\S]*?)\n\};/);
|
||||
if (!block) return [];
|
||||
return [...block[1].matchAll(/^ {2}([a-z-]+)[:,]/gm)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
function capabilityIds(source) {
|
||||
return [
|
||||
...new Set(
|
||||
[...source.matchAll(/entry\("([a-z-]+:[a-z-]+)"/g)].map((m) => m[1]),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function entryPoints(pkg) {
|
||||
return Object.keys(pkg.exports ?? {})
|
||||
.filter((key) => key !== "." && !INTERNAL_EXPORTS.has(key))
|
||||
.map((key) => `@termix-ssh/plugin-sdk/${key.slice(2)}`);
|
||||
}
|
||||
|
||||
function apiVersion(source) {
|
||||
return source.match(/PLUGIN_API_VERSION = "([^"]+)"/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function findProblems({
|
||||
guide,
|
||||
commands,
|
||||
capabilities,
|
||||
entries,
|
||||
sdkVersion,
|
||||
api,
|
||||
}) {
|
||||
const problems = [];
|
||||
for (const command of commands) {
|
||||
// `sign <file>` counts as well as `sign`.
|
||||
if (!new RegExp(`\`${command}[\` ]`).test(guide))
|
||||
problems.push(`CLI command "${command}" is not covered`);
|
||||
}
|
||||
for (const id of capabilities) {
|
||||
if (!guide.includes(`\`${id}\``))
|
||||
problems.push(`capability "${id}" is not covered`);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!guide.includes(`\`${entry}\``))
|
||||
problems.push(`entry point "${entry}" is not covered`);
|
||||
}
|
||||
const header = guide.match(/^SDK (\S+) \| Plugin API (\S+)$/m);
|
||||
if (!header) {
|
||||
problems.push('missing the "SDK x.y.z | Plugin API x.y.z" header line');
|
||||
} else {
|
||||
if (header[1] !== sdkVersion) {
|
||||
problems.push(`header says SDK ${header[1]}, package is ${sdkVersion}`);
|
||||
}
|
||||
if (api && header[2] !== api) {
|
||||
problems.push(`header says Plugin API ${header[2]}, SDK has ${api}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const read = (file) => fs.readFileSync(path.join(SDK, file), "utf8");
|
||||
const pkg = JSON.parse(read("package.json"));
|
||||
const problems = findProblems({
|
||||
guide: read("PLUGIN-GUIDE.md"),
|
||||
commands: cliCommands(read("cli/index.mjs")),
|
||||
capabilities: capabilityIds(read("src/capabilities.ts")),
|
||||
entries: entryPoints(pkg),
|
||||
sdkVersion: pkg.version,
|
||||
api: apiVersion(read("src/manifest.ts")),
|
||||
});
|
||||
if (!pkg.files?.includes("PLUGIN-GUIDE.md")) {
|
||||
problems.push(
|
||||
'"PLUGIN-GUIDE.md" is not in the SDK package.json files list',
|
||||
);
|
||||
}
|
||||
if (problems.length > 0) {
|
||||
console.error(
|
||||
"check-sdk-guide: update packages/plugin-sdk/PLUGIN-GUIDE.md",
|
||||
);
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
cliCommands,
|
||||
capabilityIds,
|
||||
entryPoints,
|
||||
apiVersion,
|
||||
findProblems,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { cliCommands, capabilityIds, entryPoints, apiVersion, findProblems } =
|
||||
require("./check-sdk-guide.cjs") as {
|
||||
cliCommands: (source: string) => string[];
|
||||
capabilityIds: (source: string) => string[];
|
||||
entryPoints: (pkg: { exports?: Record<string, unknown> }) => string[];
|
||||
apiVersion: (source: string) => string | null;
|
||||
findProblems: (input: {
|
||||
guide: string;
|
||||
commands: string[];
|
||||
capabilities: string[];
|
||||
entries: string[];
|
||||
sdkVersion: string;
|
||||
api: string | null;
|
||||
}) => string[];
|
||||
};
|
||||
|
||||
const GUIDE = [
|
||||
"# Guide",
|
||||
"",
|
||||
"SDK 1.2.3 | Plugin API 1.4.0",
|
||||
"",
|
||||
"| `build` | builds |",
|
||||
"| `sign <file>` | signs |",
|
||||
"| `db:own` | tables |",
|
||||
"| `@termix-ssh/plugin-sdk/db` | tables |",
|
||||
].join("\n");
|
||||
|
||||
const input = {
|
||||
guide: GUIDE,
|
||||
commands: ["build", "sign"],
|
||||
capabilities: ["db:own"],
|
||||
entries: ["@termix-ssh/plugin-sdk/db"],
|
||||
sdkVersion: "1.2.3",
|
||||
api: "1.4.0",
|
||||
};
|
||||
|
||||
describe("readers", () => {
|
||||
it("reads command names from the COMMANDS map", () => {
|
||||
const source = [
|
||||
"const COMMANDS = {",
|
||||
" build,",
|
||||
" patch: async () => {},",
|
||||
" test: async (options) => {",
|
||||
" return run(options);",
|
||||
" },",
|
||||
" verify,",
|
||||
"};",
|
||||
].join("\n");
|
||||
expect(cliCommands(source)).toEqual(["build", "patch", "test", "verify"]);
|
||||
});
|
||||
|
||||
it("reads capability ids once each", () => {
|
||||
const source =
|
||||
'entry("db:own", "low"),\nentry("ssh:connect", "high"),\nentry("db:own", "low")';
|
||||
expect(capabilityIds(source)).toEqual(["db:own", "ssh:connect"]);
|
||||
});
|
||||
|
||||
it("skips the root and internal exports", () => {
|
||||
expect(
|
||||
entryPoints({
|
||||
exports: {
|
||||
".": {},
|
||||
"./db": {},
|
||||
"./testing/setup": {},
|
||||
"./theme.css": "",
|
||||
},
|
||||
}),
|
||||
).toEqual(["@termix-ssh/plugin-sdk/db"]);
|
||||
});
|
||||
|
||||
it("reads the plugin API version", () => {
|
||||
expect(apiVersion('export const PLUGIN_API_VERSION = "1.2.0";')).toBe(
|
||||
"1.2.0",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findProblems", () => {
|
||||
it("passes a guide that covers everything", () => {
|
||||
expect(findProblems(input)).toEqual([]);
|
||||
});
|
||||
|
||||
it("names what is missing", () => {
|
||||
expect(
|
||||
findProblems({
|
||||
...input,
|
||||
commands: ["build", "dev"],
|
||||
capabilities: ["db:own", "kv:own"],
|
||||
entries: ["@termix-ssh/plugin-sdk/ui"],
|
||||
}),
|
||||
).toEqual([
|
||||
'CLI command "dev" is not covered',
|
||||
'capability "kv:own" is not covered',
|
||||
'entry point "@termix-ssh/plugin-sdk/ui" is not covered',
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails when the header is behind the SDK", () => {
|
||||
expect(
|
||||
findProblems({ ...input, sdkVersion: "1.2.4", api: "1.5.0" }),
|
||||
).toEqual([
|
||||
"header says SDK 1.2.3, package is 1.2.4",
|
||||
"header says Plugin API 1.4.0, SDK has 1.5.0",
|
||||
]);
|
||||
expect(findProblems({ ...input, guide: "no header" })).toContain(
|
||||
'missing the "SDK x.y.z | Plugin API x.y.z" header line',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user