@ericsanchezok/synergy-plugin-kit 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +25 -0
- package/dist/cmd.d.ts +6 -0
- package/dist/cmd.js +3 -0
- package/dist/commands/build.d.ts +6 -0
- package/dist/commands/build.js +194 -0
- package/dist/commands/create.d.ts +9 -0
- package/dist/commands/create.js +416 -0
- package/dist/commands/dev.d.ts +9 -0
- package/dist/commands/dev.js +192 -0
- package/dist/commands/entry.d.ts +19 -0
- package/dist/commands/entry.js +71 -0
- package/dist/commands/index.d.ts +9 -0
- package/dist/commands/index.js +9 -0
- package/dist/commands/pack.d.ts +8 -0
- package/dist/commands/pack.js +64 -0
- package/dist/commands/publish-market.d.ts +23 -0
- package/dist/commands/publish-market.js +224 -0
- package/dist/commands/sign.d.ts +10 -0
- package/dist/commands/sign.js +120 -0
- package/dist/commands/test.d.ts +5 -0
- package/dist/commands/test.js +40 -0
- package/dist/commands/validate.d.ts +10 -0
- package/dist/commands/validate.js +348 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/lib/capability.d.ts +2 -0
- package/dist/lib/capability.js +42 -0
- package/dist/lib/crypto.d.ts +5 -0
- package/dist/lib/crypto.js +27 -0
- package/dist/lib/hash.d.ts +3 -0
- package/dist/lib/hash.js +14 -0
- package/dist/lib/ids.d.ts +3 -0
- package/dist/lib/ids.js +7 -0
- package/dist/lib/market-entry.d.ts +57 -0
- package/dist/lib/market-entry.js +181 -0
- package/dist/lib/paths.d.ts +3 -0
- package/dist/lib/paths.js +5 -0
- package/dist/lib/risk.d.ts +2 -0
- package/dist/lib/risk.js +28 -0
- package/dist/lib/runtime-discovery.d.ts +12 -0
- package/dist/lib/runtime-discovery.js +13 -0
- package/dist/lib/runtime-mode.d.ts +9 -0
- package/dist/lib/runtime-mode.js +15 -0
- package/dist/lib/runtime-policy.d.ts +12 -0
- package/dist/lib/runtime-policy.js +62 -0
- package/dist/lib/signature.d.ts +15 -0
- package/dist/lib/signature.js +15 -0
- package/dist/lib/spec.d.ts +7 -0
- package/dist/lib/spec.js +21 -0
- package/dist/ui.d.ts +15 -0
- package/dist/ui.js +26 -0
- package/package.json +43 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { EOL } from "os";
|
|
4
|
+
import { subtle } from "node:crypto";
|
|
5
|
+
import { PluginManifest } from "@ericsanchezok/synergy-plugin";
|
|
6
|
+
import { cmd } from "../cmd";
|
|
7
|
+
import { UI } from "../ui";
|
|
8
|
+
import { SIGNING_KEYS_DIR, SIGNING_KEY_FILE } from "../lib/paths";
|
|
9
|
+
import { sha256Content, sha256File } from "../lib/crypto";
|
|
10
|
+
function extractFromTarball(tarballPath, memberPath) {
|
|
11
|
+
const result = Bun.spawnSync(["tar", "-xOf", tarballPath, memberPath], { stdout: "pipe", stderr: "pipe" });
|
|
12
|
+
if (result.exitCode !== 0)
|
|
13
|
+
return null;
|
|
14
|
+
return new TextDecoder().decode(result.stdout);
|
|
15
|
+
}
|
|
16
|
+
function readKeyFile() {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(SIGNING_KEY_FILE, "utf-8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeKeyFile(key) {
|
|
25
|
+
fs.mkdirSync(SIGNING_KEYS_DIR, { recursive: true });
|
|
26
|
+
fs.writeFileSync(SIGNING_KEY_FILE, JSON.stringify(key, null, 2), { mode: 0o600 });
|
|
27
|
+
}
|
|
28
|
+
async function generateKeyPair() {
|
|
29
|
+
const key = (await subtle.generateKey("Ed25519", true, ["sign", "verify"]));
|
|
30
|
+
const privRaw = await subtle.exportKey("pkcs8", key.privateKey);
|
|
31
|
+
const pubRaw = await subtle.exportKey("raw", key.publicKey);
|
|
32
|
+
return {
|
|
33
|
+
privateKey: Buffer.from(privRaw).toString("hex"),
|
|
34
|
+
publicKey: Buffer.from(pubRaw).toString("hex"),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function importPrivateKey(hex) {
|
|
38
|
+
return subtle.importKey("pkcs8", Buffer.from(hex, "hex"), "Ed25519", false, ["sign"]);
|
|
39
|
+
}
|
|
40
|
+
export async function signPluginTarball(tarballPath, options = {}) {
|
|
41
|
+
if (!fs.existsSync(tarballPath))
|
|
42
|
+
throw new Error(`Tarball not found: ${tarballPath}`);
|
|
43
|
+
UI.println(`${UI.Style.TEXT_NORMAL_BOLD}Signing${UI.Style.TEXT_NORMAL} ${path.basename(tarballPath)}`);
|
|
44
|
+
const tarballHash = sha256File(tarballPath);
|
|
45
|
+
const manifestRaw = extractFromTarball(tarballPath, "plugin.normalized.json");
|
|
46
|
+
if (!manifestRaw)
|
|
47
|
+
throw new Error("Failed to extract plugin.normalized.json from tarball. Has the plugin been built?");
|
|
48
|
+
let manifest;
|
|
49
|
+
try {
|
|
50
|
+
manifest = PluginManifest.parse(JSON.parse(manifestRaw));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error("Failed to parse plugin.normalized.json from tarball");
|
|
54
|
+
}
|
|
55
|
+
const permissionsRaw = extractFromTarball(tarballPath, "permissions.summary.json");
|
|
56
|
+
if (!permissionsRaw) {
|
|
57
|
+
throw new Error("Failed to extract permissions.summary.json from tarball. Has the plugin been built?");
|
|
58
|
+
}
|
|
59
|
+
let keyFile = readKeyFile();
|
|
60
|
+
let isNewKey = false;
|
|
61
|
+
if (!keyFile) {
|
|
62
|
+
UI.println(` ${UI.Style.TEXT_DIM}No signing key found. Generating new ed25519 keypair...${UI.Style.TEXT_NORMAL}`);
|
|
63
|
+
keyFile = await generateKeyPair();
|
|
64
|
+
writeKeyFile(keyFile);
|
|
65
|
+
isNewKey = true;
|
|
66
|
+
}
|
|
67
|
+
const payload = {
|
|
68
|
+
tarballHash,
|
|
69
|
+
manifestHash: sha256Content(manifestRaw),
|
|
70
|
+
permissionsHash: sha256Content(permissionsRaw),
|
|
71
|
+
};
|
|
72
|
+
const privateKey = await importPrivateKey(keyFile.privateKey);
|
|
73
|
+
const sigRaw = await subtle.sign("Ed25519", privateKey, new TextEncoder().encode(JSON.stringify(payload)));
|
|
74
|
+
const signature = {
|
|
75
|
+
signatureVersion: 1,
|
|
76
|
+
pluginId: manifest.name,
|
|
77
|
+
version: manifest.version,
|
|
78
|
+
algorithm: "ed25519",
|
|
79
|
+
signer: keyFile.publicKey,
|
|
80
|
+
signature: Buffer.from(sigRaw).toString("hex"),
|
|
81
|
+
signedAt: Date.now(),
|
|
82
|
+
payload,
|
|
83
|
+
};
|
|
84
|
+
const sigPath = `${tarballPath}.sig`;
|
|
85
|
+
const rendered = JSON.stringify(signature, null, 2) + EOL;
|
|
86
|
+
fs.writeFileSync(sigPath, rendered);
|
|
87
|
+
if (options.stdout)
|
|
88
|
+
process.stdout.write(rendered);
|
|
89
|
+
UI.println(`${UI.Style.TEXT_SUCCESS}✔${UI.Style.TEXT_NORMAL} Signed ${manifest.name} v${manifest.version}`);
|
|
90
|
+
if (isNewKey)
|
|
91
|
+
UI.println(` ${UI.Style.TEXT_DIM}New signing key generated${UI.Style.TEXT_NORMAL}`);
|
|
92
|
+
UI.println(` ${UI.Style.TEXT_DIM}Signature:${UI.Style.TEXT_NORMAL} ${sigPath}`);
|
|
93
|
+
UI.println(` ${UI.Style.TEXT_DIM}Signer:${UI.Style.TEXT_NORMAL} ${keyFile.publicKey.slice(0, 16)}...`);
|
|
94
|
+
UI.println(` ${UI.Style.TEXT_DIM}Key stored at:${UI.Style.TEXT_NORMAL} ${SIGNING_KEY_FILE}`);
|
|
95
|
+
return sigPath;
|
|
96
|
+
}
|
|
97
|
+
export const PluginSignCommand = cmd({
|
|
98
|
+
command: "sign <tarball>",
|
|
99
|
+
describe: "sign a plugin package tarball",
|
|
100
|
+
builder: (yargs) => yargs
|
|
101
|
+
.positional("tarball", {
|
|
102
|
+
type: "string",
|
|
103
|
+
describe: "path to .synergy-plugin.tgz tarball",
|
|
104
|
+
demandOption: true,
|
|
105
|
+
})
|
|
106
|
+
.option("stdout", {
|
|
107
|
+
type: "boolean",
|
|
108
|
+
default: false,
|
|
109
|
+
describe: "also print the signature JSON to stdout",
|
|
110
|
+
}),
|
|
111
|
+
async handler(args) {
|
|
112
|
+
try {
|
|
113
|
+
await signPluginTarball(path.resolve(args.tarball), { stdout: Boolean(args.stdout) });
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
UI.error(error instanceof Error ? error.message : String(error));
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { cmd } from "../cmd";
|
|
4
|
+
import { UI } from "../ui";
|
|
5
|
+
function findTestFiles(pluginDir) {
|
|
6
|
+
const testDir = path.join(pluginDir, "test");
|
|
7
|
+
if (!fs.existsSync(testDir))
|
|
8
|
+
return [];
|
|
9
|
+
return fs
|
|
10
|
+
.readdirSync(testDir)
|
|
11
|
+
.filter((file) => file.endsWith(".test.ts") || file.endsWith(".test.tsx") || file.endsWith(".spec.ts"));
|
|
12
|
+
}
|
|
13
|
+
export const PluginTestCommand = cmd({
|
|
14
|
+
command: "test [path]",
|
|
15
|
+
describe: "run plugin tests",
|
|
16
|
+
builder: (yargs) => yargs.positional("path", {
|
|
17
|
+
type: "string",
|
|
18
|
+
describe: "path to plugin directory (defaults to cwd)",
|
|
19
|
+
}),
|
|
20
|
+
async handler(args) {
|
|
21
|
+
const pluginDir = path.resolve(args.path ?? process.cwd());
|
|
22
|
+
if (!fs.existsSync(pluginDir)) {
|
|
23
|
+
UI.error(`Directory not found: ${pluginDir}`);
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const testFiles = findTestFiles(pluginDir);
|
|
28
|
+
if (testFiles.length === 0) {
|
|
29
|
+
UI.println(`${UI.Style.TEXT_DIM}No plugin tests found. Add test/*.test.ts files and rerun synergy-plugin test.${UI.Style.TEXT_NORMAL}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
UI.println(`${UI.Style.TEXT_NORMAL_BOLD}Running tests${UI.Style.TEXT_NORMAL} in ${pluginDir}`);
|
|
33
|
+
UI.println(`${UI.Style.TEXT_DIM}Test files found: ${testFiles.join(", ")}${UI.Style.TEXT_NORMAL}`);
|
|
34
|
+
UI.println();
|
|
35
|
+
const proc = Bun.spawn(["bun", "test"], { cwd: pluginDir, stdout: "inherit", stderr: "inherit" });
|
|
36
|
+
const code = await proc.exited;
|
|
37
|
+
if (code !== 0)
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function validatePluginProject(pluginPath: string, options?: {
|
|
2
|
+
runtimeDiscovery?: boolean;
|
|
3
|
+
}): Promise<void>;
|
|
4
|
+
export declare const PluginValidateCommand: import("yargs").CommandModule<{}, {
|
|
5
|
+
path: string | undefined;
|
|
6
|
+
} & {
|
|
7
|
+
"runtime-discovery": boolean;
|
|
8
|
+
} & {
|
|
9
|
+
"--"?: string[];
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { EOL } from "os";
|
|
4
|
+
import { PluginManifest } from "@ericsanchezok/synergy-plugin";
|
|
5
|
+
import { cmd } from "../cmd";
|
|
6
|
+
import { UI } from "../ui";
|
|
7
|
+
import { PluginId } from "../lib/ids";
|
|
8
|
+
import { baseCapabilities } from "../lib/capability";
|
|
9
|
+
import { computeRisk } from "../lib/risk";
|
|
10
|
+
import { validateRuntimePolicy } from "../lib/runtime-policy";
|
|
11
|
+
import { validateRuntimeDiscovery } from "../lib/runtime-discovery";
|
|
12
|
+
import { assertCanonicalPluginIdentity, importUrlForEntry, resolveEntryFromPluginDir } from "../lib/spec";
|
|
13
|
+
function scanExports(source) {
|
|
14
|
+
const names = new Set();
|
|
15
|
+
const declRe = /^export\s+(?:const|function|class|interface|type|let|var)\s+(\w+)/gm;
|
|
16
|
+
let match;
|
|
17
|
+
while ((match = declRe.exec(source)) !== null)
|
|
18
|
+
names.add(match[1]);
|
|
19
|
+
const listRe = /^export\s*\{([^}]+)\}/gm;
|
|
20
|
+
while ((match = listRe.exec(source)) !== null) {
|
|
21
|
+
for (const spec of match[1].split(",")) {
|
|
22
|
+
const name = spec
|
|
23
|
+
.trim()
|
|
24
|
+
.split(/\s+as\s+/)[0]
|
|
25
|
+
?.trim();
|
|
26
|
+
if (name)
|
|
27
|
+
names.add(name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (/^export\s+default\s+(?:function|class|async\s+function)\b/m.test(source))
|
|
31
|
+
names.add("default");
|
|
32
|
+
if (/^export\s+default\s+[$A-Z_a-z][$\w]*\s*;?$/m.test(source))
|
|
33
|
+
names.add("default");
|
|
34
|
+
return [...names];
|
|
35
|
+
}
|
|
36
|
+
function isValidJsonSchema(obj) {
|
|
37
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj))
|
|
38
|
+
return false;
|
|
39
|
+
const schema = obj;
|
|
40
|
+
const keywords = [
|
|
41
|
+
"type",
|
|
42
|
+
"properties",
|
|
43
|
+
"required",
|
|
44
|
+
"items",
|
|
45
|
+
"anyOf",
|
|
46
|
+
"oneOf",
|
|
47
|
+
"allOf",
|
|
48
|
+
"enum",
|
|
49
|
+
"const",
|
|
50
|
+
"additionalProperties",
|
|
51
|
+
"patternProperties",
|
|
52
|
+
"$ref",
|
|
53
|
+
"$defs",
|
|
54
|
+
"definitions",
|
|
55
|
+
"title",
|
|
56
|
+
"description",
|
|
57
|
+
"default",
|
|
58
|
+
"examples",
|
|
59
|
+
"format",
|
|
60
|
+
"minimum",
|
|
61
|
+
"maximum",
|
|
62
|
+
"minLength",
|
|
63
|
+
"maxLength",
|
|
64
|
+
"minItems",
|
|
65
|
+
"maxItems",
|
|
66
|
+
"uniqueItems",
|
|
67
|
+
"pattern",
|
|
68
|
+
];
|
|
69
|
+
return keywords.some((key) => key in schema);
|
|
70
|
+
}
|
|
71
|
+
function findUiSource(pluginDir) {
|
|
72
|
+
const candidates = ["src/ui.tsx", "src/ui/index.tsx", "src/ui.ts", "src/ui/index.ts"];
|
|
73
|
+
return candidates.map((candidate) => path.join(pluginDir, candidate)).find((candidate) => fs.existsSync(candidate));
|
|
74
|
+
}
|
|
75
|
+
function printResults(results) {
|
|
76
|
+
const passCount = results.filter((result) => result.type === "pass").length;
|
|
77
|
+
const warnCount = results.filter((result) => result.type === "warn").length;
|
|
78
|
+
const errorCount = results.filter((result) => result.type === "error").length;
|
|
79
|
+
for (const result of results) {
|
|
80
|
+
const prefix = result.type === "pass"
|
|
81
|
+
? `${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL}`
|
|
82
|
+
: result.type === "warn"
|
|
83
|
+
? `${UI.Style.TEXT_WARNING}⚠${UI.Style.TEXT_NORMAL}`
|
|
84
|
+
: `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}`;
|
|
85
|
+
process.stdout.write(`${prefix} ${result.message}${EOL}`);
|
|
86
|
+
}
|
|
87
|
+
process.stdout.write(EOL);
|
|
88
|
+
const parts = [];
|
|
89
|
+
if (passCount > 0)
|
|
90
|
+
parts.push(`${passCount} passed`);
|
|
91
|
+
if (warnCount > 0)
|
|
92
|
+
parts.push(`${warnCount} warning${warnCount !== 1 ? "s" : ""}`);
|
|
93
|
+
if (errorCount > 0)
|
|
94
|
+
parts.push(`${errorCount} error${errorCount !== 1 ? "s" : ""}`);
|
|
95
|
+
process.stdout.write(parts.join(", ") + EOL);
|
|
96
|
+
if (errorCount > 0)
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
}
|
|
99
|
+
export async function validatePluginProject(pluginPath, options = {}) {
|
|
100
|
+
const results = [];
|
|
101
|
+
const resolved = path.isAbsolute(pluginPath) ? pluginPath : path.resolve(process.cwd(), pluginPath);
|
|
102
|
+
const manifestPath = fs.statSync(resolved).isDirectory() ? path.join(resolved, "plugin.json") : resolved;
|
|
103
|
+
const pluginDir = path.dirname(manifestPath);
|
|
104
|
+
let rawManifest;
|
|
105
|
+
let manifest = null;
|
|
106
|
+
try {
|
|
107
|
+
rawManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
108
|
+
manifest = PluginManifest.safeParse(rawManifest);
|
|
109
|
+
if (manifest.success) {
|
|
110
|
+
results.push({ type: "pass", message: "manifest schema valid" });
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const issues = manifest.error.issues.map((issue) => ` • ${issue.path.join(".")}: ${issue.message}`).join(EOL);
|
|
114
|
+
results.push({ type: "error", message: `manifest schema invalid${EOL}${issues}` });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
119
|
+
results.push({
|
|
120
|
+
type: "error",
|
|
121
|
+
message: rawManifest === undefined ? `manifest not found at ${manifestPath}` : `invalid JSON: ${msg}`,
|
|
122
|
+
});
|
|
123
|
+
printResults(results);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!manifest?.success) {
|
|
127
|
+
printResults(results);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const m = manifest.data;
|
|
131
|
+
const id = rawManifest?.id ?? m.name;
|
|
132
|
+
if (id && PluginId.isValid(id))
|
|
133
|
+
results.push({ type: "pass", message: `id "${id}" valid` });
|
|
134
|
+
else
|
|
135
|
+
results.push({ type: "error", message: `id "${id ?? ""}" invalid - must be lowercase alphanumeric with dashes` });
|
|
136
|
+
if (m.version)
|
|
137
|
+
results.push({ type: "pass", message: `version ${m.version}` });
|
|
138
|
+
else
|
|
139
|
+
results.push({ type: "error", message: "version missing" });
|
|
140
|
+
if (m.contributes?.tools && m.contributes.tools.length > 0) {
|
|
141
|
+
if (m.permissions?.tools)
|
|
142
|
+
results.push({ type: "pass", message: "permissions declared" });
|
|
143
|
+
else
|
|
144
|
+
results.push({ type: "error", message: "tools contributed but permissions.tools not declared" });
|
|
145
|
+
}
|
|
146
|
+
if (m.contributes?.ui) {
|
|
147
|
+
const ui = m.contributes.ui;
|
|
148
|
+
if (ui.entry) {
|
|
149
|
+
const entryPath = path.resolve(pluginDir, ui.entry);
|
|
150
|
+
const uiSource = findUiSource(pluginDir);
|
|
151
|
+
if (fs.existsSync(entryPath)) {
|
|
152
|
+
results.push({ type: "pass", message: `UI entry ${ui.entry} exists` });
|
|
153
|
+
}
|
|
154
|
+
else if (uiSource) {
|
|
155
|
+
results.push({
|
|
156
|
+
type: "pass",
|
|
157
|
+
message: `UI entry ${ui.entry} will be built from ${path.relative(pluginDir, uiSource)}`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
results.push({ type: "error", message: `UI entry ${ui.entry} not found` });
|
|
162
|
+
}
|
|
163
|
+
if (uiSource) {
|
|
164
|
+
const exports = scanExports(fs.readFileSync(uiSource, "utf-8"));
|
|
165
|
+
const checkExport = (category, items) => {
|
|
166
|
+
for (const item of items ?? []) {
|
|
167
|
+
const exportName = item.exportName || "default";
|
|
168
|
+
if (!exports.includes(exportName)) {
|
|
169
|
+
const label = item.id ?? item.tool ?? exportName;
|
|
170
|
+
results.push({
|
|
171
|
+
type: "error",
|
|
172
|
+
message: `${category} "${label}" exportName "${exportName}" not found in UI entry`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
checkExport("workspacePanel", ui.workspacePanels);
|
|
178
|
+
checkExport("globalPanel", ui.globalPanels);
|
|
179
|
+
checkExport("settings", ui.settings);
|
|
180
|
+
checkExport("toolRenderer", ui.toolRenderers);
|
|
181
|
+
checkExport("partRenderer", ui.partRenderers);
|
|
182
|
+
checkExport("chatComponent", ui.chatComponents);
|
|
183
|
+
checkExport("uiCommand", ui.commands);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const icon of ui.icons ?? []) {
|
|
187
|
+
if (!fs.existsSync(path.resolve(pluginDir, icon.path))) {
|
|
188
|
+
results.push({ type: "error", message: `icon "${icon.name}" path ${icon.path} not found` });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const theme of ui.themes ?? []) {
|
|
192
|
+
if (!fs.existsSync(path.resolve(pluginDir, theme.path))) {
|
|
193
|
+
results.push({ type: "error", message: `theme "${theme.id}" path ${theme.path} not found` });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const route of ui.routes ?? []) {
|
|
197
|
+
if (!fs.existsSync(path.resolve(pluginDir, route.entry))) {
|
|
198
|
+
results.push({ type: "error", message: `route "${route.path}" entry ${route.entry} not found` });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const tool of m.contributes?.tools ?? []) {
|
|
203
|
+
if (!tool.capabilities)
|
|
204
|
+
results.push({ type: "warn", message: `tool "${tool.name}" missing capabilities declaration` });
|
|
205
|
+
}
|
|
206
|
+
if (m.contributes?.config?.schema) {
|
|
207
|
+
if (isValidJsonSchema(m.contributes.config.schema))
|
|
208
|
+
results.push({ type: "pass", message: "config schema valid" });
|
|
209
|
+
else
|
|
210
|
+
results.push({ type: "warn", message: "config schema does not appear to be valid JSON Schema" });
|
|
211
|
+
}
|
|
212
|
+
const pluginRisk = computeRisk(baseCapabilities(m), m);
|
|
213
|
+
results.push(...validateRuntimePolicy({ manifest: m, source: "local", trustTier: "declarative", risk: pluginRisk }));
|
|
214
|
+
if (options.runtimeDiscovery) {
|
|
215
|
+
const manifestToolNames = (m.contributes?.tools ?? []).map((tool) => tool.name);
|
|
216
|
+
const resolvedEntry = resolveEntryFromPluginDir(pluginDir, m);
|
|
217
|
+
const entryPath = fs.existsSync(resolvedEntry) ? resolvedEntry : null;
|
|
218
|
+
if (!entryPath) {
|
|
219
|
+
results.push({
|
|
220
|
+
type: "warn",
|
|
221
|
+
message: "runtime-discovery: no build output found - run 'synergy-plugin build' first",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
try {
|
|
226
|
+
const mod = await import(importUrlForEntry(entryPath, Date.now()));
|
|
227
|
+
const descriptors = [];
|
|
228
|
+
for (const value of Object.values(mod)) {
|
|
229
|
+
if (value && typeof value === "object" && !Array.isArray(value) && "id" in value && "init" in value) {
|
|
230
|
+
descriptors.push(value);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (descriptors.length === 0) {
|
|
234
|
+
results.push({
|
|
235
|
+
type: "warn",
|
|
236
|
+
message: `runtime-discovery: no PluginDescriptor found in ${path.relative(process.cwd(), entryPath)}`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
for (const desc of descriptors) {
|
|
241
|
+
try {
|
|
242
|
+
assertCanonicalPluginIdentity({ manifest: m, descriptor: desc });
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
results.push({ type: "error", message: error instanceof Error ? error.message : String(error) });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
let hooks;
|
|
249
|
+
let loadError;
|
|
250
|
+
try {
|
|
251
|
+
const input = {
|
|
252
|
+
client: undefined,
|
|
253
|
+
scope: undefined,
|
|
254
|
+
worktree: "",
|
|
255
|
+
directory: pluginDir,
|
|
256
|
+
serverUrl: new URL("http://localhost"),
|
|
257
|
+
$: undefined,
|
|
258
|
+
pluginDir,
|
|
259
|
+
config: { get: async () => ({}), set: async () => { } },
|
|
260
|
+
auth: {
|
|
261
|
+
get: async () => undefined,
|
|
262
|
+
set: async () => { },
|
|
263
|
+
delete: async () => { },
|
|
264
|
+
has: async () => false,
|
|
265
|
+
},
|
|
266
|
+
cache: {
|
|
267
|
+
directory: path.join(pluginDir, ".cache"),
|
|
268
|
+
get: async () => undefined,
|
|
269
|
+
set: async () => { },
|
|
270
|
+
delete: async () => { },
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
hooks = await desc.init(input);
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
loadError = error instanceof Error ? error.message : String(error);
|
|
277
|
+
}
|
|
278
|
+
const runtimeToolNames = hooks?.tool ? Object.keys(hooks.tool) : loadError ? null : [];
|
|
279
|
+
const discovery = validateRuntimeDiscovery({ manifestToolNames, runtimeToolNames, pluginId: desc.id });
|
|
280
|
+
results.push({
|
|
281
|
+
type: "pass",
|
|
282
|
+
message: `runtime-discovery: loaded plugin "${desc.id}"${loadError ? ` (init failed: ${loadError})` : ""}`,
|
|
283
|
+
});
|
|
284
|
+
if (discovery.loadFailed) {
|
|
285
|
+
results.push({
|
|
286
|
+
type: "error",
|
|
287
|
+
message: `runtime-discovery: plugin "${desc.id}" failed to initialize - cannot validate tool registration`,
|
|
288
|
+
});
|
|
289
|
+
if (loadError)
|
|
290
|
+
results.push({ type: "error", message: ` init error: ${loadError}` });
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
const total = runtimeToolNames !== null ? runtimeToolNames.length : 0;
|
|
294
|
+
results.push({
|
|
295
|
+
type: "pass",
|
|
296
|
+
message: `runtime-discovery: ${total} tool(s) registered at runtime, ${manifestToolNames.length} declared in manifest`,
|
|
297
|
+
});
|
|
298
|
+
if (discovery.matched.length > 0) {
|
|
299
|
+
results.push({
|
|
300
|
+
type: "pass",
|
|
301
|
+
message: `runtime-discovery: ${discovery.matched.length} tool(s) matched - ${discovery.matched.join(", ")}`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
if (discovery.undeclared.length > 0) {
|
|
305
|
+
results.push({
|
|
306
|
+
type: "error",
|
|
307
|
+
message: `runtime-discovery: ${discovery.undeclared.length} undeclared tool(s) - ${discovery.undeclared.join(", ")}`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (discovery.declaredButMissing.length > 0) {
|
|
311
|
+
results.push({
|
|
312
|
+
type: "warn",
|
|
313
|
+
message: `runtime-discovery: ${discovery.declaredButMissing.length} tool(s) declared but not registered - ${discovery.declaredButMissing.join(", ")}`,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
results.push({
|
|
322
|
+
type: "error",
|
|
323
|
+
message: `runtime-discovery: failed to load plugin - ${error instanceof Error ? error.message : String(error)}`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
printResults(results);
|
|
329
|
+
}
|
|
330
|
+
export const PluginValidateCommand = cmd({
|
|
331
|
+
command: "validate [path]",
|
|
332
|
+
describe: "validate a plugin manifest",
|
|
333
|
+
builder: (yargs) => yargs
|
|
334
|
+
.positional("path", {
|
|
335
|
+
type: "string",
|
|
336
|
+
describe: "path to plugin directory or plugin.json (defaults to current directory)",
|
|
337
|
+
})
|
|
338
|
+
.option("runtime-discovery", {
|
|
339
|
+
type: "boolean",
|
|
340
|
+
describe: "safely load plugin in dev mode, collect runtime tools, and compare with manifest",
|
|
341
|
+
default: false,
|
|
342
|
+
}),
|
|
343
|
+
async handler(args) {
|
|
344
|
+
await validatePluginProject(args.path || process.cwd(), {
|
|
345
|
+
runtimeDiscovery: Boolean(args["runtime-discovery"]),
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function buildCapabilitySet(permissions, toolOverrides) {
|
|
2
|
+
const caps = new Set(["plugin_invoke"]);
|
|
3
|
+
const pt = permissions?.tools;
|
|
4
|
+
const pd = permissions?.data;
|
|
5
|
+
const tc = toolOverrides;
|
|
6
|
+
const fs = tc?.filesystem ?? pt?.filesystem ?? "none";
|
|
7
|
+
if (fs === "read")
|
|
8
|
+
caps.add("filesystem:read");
|
|
9
|
+
if (fs === "write") {
|
|
10
|
+
caps.add("filesystem:read");
|
|
11
|
+
caps.add("filesystem:write");
|
|
12
|
+
}
|
|
13
|
+
if (tc?.shell ?? pt?.shell ?? false)
|
|
14
|
+
caps.add("shell");
|
|
15
|
+
if (tc?.network ?? pt?.network ?? false)
|
|
16
|
+
caps.add("network");
|
|
17
|
+
if (pt?.mcp === "invoke")
|
|
18
|
+
caps.add("mcp:invoke");
|
|
19
|
+
if (pt?.mcp === "spawn") {
|
|
20
|
+
caps.add("mcp:invoke");
|
|
21
|
+
caps.add("mcp:spawn");
|
|
22
|
+
}
|
|
23
|
+
const sess = tc?.session ?? pd?.session ?? "none";
|
|
24
|
+
if (sess === "read")
|
|
25
|
+
caps.add("session_data");
|
|
26
|
+
const ws = tc?.workspace ?? pd?.workspace ?? "none";
|
|
27
|
+
if (ws === "read")
|
|
28
|
+
caps.add("workspace_data");
|
|
29
|
+
const cfg = tc?.config ?? pd?.config ?? "plugin";
|
|
30
|
+
if (cfg === "global") {
|
|
31
|
+
caps.add("config:read");
|
|
32
|
+
caps.add("config:write");
|
|
33
|
+
}
|
|
34
|
+
if (cfg === "plugin")
|
|
35
|
+
caps.add("config:read");
|
|
36
|
+
if (pd?.secrets === "own")
|
|
37
|
+
caps.add("secrets");
|
|
38
|
+
return [...caps].sort();
|
|
39
|
+
}
|
|
40
|
+
export function baseCapabilities(manifest) {
|
|
41
|
+
return buildCapabilitySet(manifest.permissions);
|
|
42
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function sha256Hex(buffer: Uint8Array): string;
|
|
2
|
+
export declare function sha256File(filePath: string): string;
|
|
3
|
+
export declare function sha256JSON(obj: unknown): string;
|
|
4
|
+
export declare function sha256Content(content: string): string;
|
|
5
|
+
export declare function sortKeys(obj: unknown): unknown;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
export function sha256Hex(buffer) {
|
|
3
|
+
return new Bun.CryptoHasher("sha256").update(buffer).digest("hex");
|
|
4
|
+
}
|
|
5
|
+
export function sha256File(filePath) {
|
|
6
|
+
const buffer = fs.readFileSync(filePath);
|
|
7
|
+
return sha256Hex(new Uint8Array(buffer));
|
|
8
|
+
}
|
|
9
|
+
export function sha256JSON(obj) {
|
|
10
|
+
return sha256Content(JSON.stringify(sortKeys(obj)));
|
|
11
|
+
}
|
|
12
|
+
export function sha256Content(content) {
|
|
13
|
+
return sha256Hex(new Uint8Array(Buffer.from(content)));
|
|
14
|
+
}
|
|
15
|
+
export function sortKeys(obj) {
|
|
16
|
+
if (Array.isArray(obj))
|
|
17
|
+
return obj.map(sortKeys);
|
|
18
|
+
if (obj && typeof obj === "object") {
|
|
19
|
+
const entries = Object.entries(obj).sort(([a], [b]) => a.localeCompare(b));
|
|
20
|
+
const result = {};
|
|
21
|
+
for (const [key, value] of entries) {
|
|
22
|
+
result[key] = sortKeys(value);
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
return obj;
|
|
27
|
+
}
|
package/dist/lib/hash.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { sha256Content, sortKeys } from "./crypto";
|
|
2
|
+
export function computePermissionsHash(manifest, capabilities) {
|
|
3
|
+
const normalized = {
|
|
4
|
+
capabilities: [...capabilities].sort(),
|
|
5
|
+
permissions: manifest.permissions ?? {},
|
|
6
|
+
contributes: manifest.contributes?.ui != null ? { ui: manifest.contributes.ui } : undefined,
|
|
7
|
+
hooks: manifest.permissions?.hooks ?? undefined,
|
|
8
|
+
};
|
|
9
|
+
return sha256Content(JSON.stringify(sortKeys(normalized)));
|
|
10
|
+
}
|
|
11
|
+
export function computeManifestHash(manifest) {
|
|
12
|
+
const { contributes, lifecycle, permissions, ...identity } = manifest;
|
|
13
|
+
return sha256Content(JSON.stringify(sortKeys(identity)));
|
|
14
|
+
}
|