@synoikia/create-plugin 0.1.0
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/LICENSE +21 -0
- package/README.md +20 -0
- package/dist/build.d.ts +16 -0
- package/dist/build.js +32 -0
- package/dist/build.js.map +1 -0
- package/dist/check.d.ts +7 -0
- package/dist/check.js +49 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +104 -0
- package/dist/cli.js.map +1 -0
- package/dist/create.d.ts +2 -0
- package/dist/create.js +56 -0
- package/dist/create.js.map +1 -0
- package/dist/files.d.ts +12 -0
- package/dist/files.js +42 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts.d.ts +46 -0
- package/dist/prompts.js +112 -0
- package/dist/prompts.js.map +1 -0
- package/dist/repo-root.d.ts +10 -0
- package/dist/repo-root.js +47 -0
- package/dist/repo-root.js.map +1 -0
- package/dist/repo.d.ts +68 -0
- package/dist/repo.js +222 -0
- package/dist/repo.js.map +1 -0
- package/dist/scaffold.d.ts +68 -0
- package/dist/scaffold.js +296 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/vitest.d.ts +12 -0
- package/dist/vitest.js +17 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +56 -0
- package/templates/plugin/blank/plugin.yaml +22 -0
- package/templates/plugin/blank/src/plugin.ts +74 -0
- package/templates/plugin/blank/test/plugin.test.ts +33 -0
- package/templates/plugin/common/README.md +23 -0
- package/templates/plugin/common/RUNBOOK.md +10 -0
- package/templates/plugin/common/src/index.ts +4 -0
- package/templates/plugin/common/tsconfig.build.json +9 -0
- package/templates/plugin/common/tsconfig.json +7 -0
- package/templates/plugin/common/vitest.config.ts +1 -0
- package/templates/plugin/openapi-rest/plugin.yaml +49 -0
- package/templates/plugin/openapi-rest/src/plugin.ts +89 -0
- package/templates/plugin/openapi-rest/test/e2e.test.ts +49 -0
- package/templates/plugin/openapi-rest/test/fake-upstream.ts +63 -0
- package/templates/plugin/openapi-rest/test/plugin.test.ts +66 -0
- package/templates/plugin/static-rest/plugin.yaml +61 -0
- package/templates/plugin/static-rest/src/plugin.ts +70 -0
- package/templates/plugin/static-rest/test/e2e.test.ts +46 -0
- package/templates/plugin/static-rest/test/fake-upstream.ts +39 -0
- package/templates/plugin/static-rest/test/plugin.test.ts +67 -0
- package/templates/plugin/websocket-rpc/plugin.yaml +45 -0
- package/templates/plugin/websocket-rpc/src/client.ts +96 -0
- package/templates/plugin/websocket-rpc/src/plugin.ts +98 -0
- package/templates/plugin/websocket-rpc/test/e2e.test.ts +46 -0
- package/templates/plugin/websocket-rpc/test/fake-upstream.ts +63 -0
- package/templates/plugin/websocket-rpc/test/plugin.test.ts +67 -0
- package/templates/repo/base/README.md +27 -0
- package/templates/repo/base/_github/workflows/ci.yml +27 -0
- package/templates/repo/base/_gitignore +6 -0
- package/templates/repo/base/_nvmrc +1 -0
- package/templates/repo/base/_prettierignore +3 -0
- package/templates/repo/base/_prettierrc +5 -0
- package/templates/repo/base/eslint.config.js +17 -0
- package/templates/repo/base/package.json +34 -0
- package/templates/repo/base/plugin-files.d.ts +10 -0
- package/templates/repo/base/pnpm-workspace.yaml +11 -0
- package/templates/repo/base/tsconfig.base.json +21 -0
- package/templates/repo/base/vitest.shared.ts +9 -0
- package/templates/repo/claude/CLAUDE.md +38 -0
- package/templates/repo/claude/_claude/agents/security-reviewer.md +33 -0
- package/templates/repo/claude/_claude/hooks/format.mjs +20 -0
- package/templates/repo/claude/_claude/hooks/guard-paths.mjs +43 -0
- package/templates/repo/claude/_claude/hooks/session-start.sh +13 -0
- package/templates/repo/claude/_claude/settings.json +21 -0
- package/templates/repo/claude/_claude/skills/add-plugin/SKILL.md +39 -0
- package/templates/repo/claude/_claude/skills/release-plugin/SKILL.md +18 -0
- package/templates/repo/release/RELEASING.md +27 -0
- package/templates/repo/release/_github/workflows/release.yml +89 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ErrorCodes, joinApiPath, PendingRequests, PluginError, singleFlight } from '@synoikia/plugin-sdk';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* JSON-RPC 2.0 over one WebSocket: connects lazily (authenticating on the upgrade), reconnects on the
|
|
6
|
+
* next call after a drop, and fails pending calls when the socket closes. Errors name the method,
|
|
7
|
+
* never its params or the credentials.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface RpcConnection {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
path: string;
|
|
13
|
+
verifyTls: boolean;
|
|
14
|
+
headers: () => Record<string, string>;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RpcError {
|
|
19
|
+
code?: number;
|
|
20
|
+
message?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class RpcClient {
|
|
24
|
+
private ws?: WebSocket;
|
|
25
|
+
private readonly pending = new PendingRequests();
|
|
26
|
+
private readonly connect = singleFlight(() => this.open());
|
|
27
|
+
|
|
28
|
+
constructor(private readonly conn: RpcConnection) {
|
|
29
|
+
joinApiPath(conn.baseUrl, conn.path, { websocket: true }); // validate early
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async call(method: string, params: unknown, timeoutMs = this.conn.timeoutMs): Promise<unknown> {
|
|
33
|
+
const ws = this.ws?.readyState === WebSocket.OPEN ? this.ws : await this.connect();
|
|
34
|
+
const { id, promise } = this.pending.start(method, timeoutMs);
|
|
35
|
+
ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} }), (err) => {
|
|
36
|
+
if (err) this.pending.fail(id, new PluginError(ErrorCodes.UpstreamError, `${method}: could not send`));
|
|
37
|
+
});
|
|
38
|
+
return promise;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
close(): void {
|
|
42
|
+
this.ws?.terminate();
|
|
43
|
+
this.ws = undefined;
|
|
44
|
+
this.pending.failAll(new PluginError(ErrorCodes.UpstreamError, 'Connection closed'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private open(): Promise<WebSocket> {
|
|
48
|
+
const url = joinApiPath(this.conn.baseUrl, this.conn.path, { websocket: true });
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const ws = new WebSocket(url, {
|
|
51
|
+
headers: this.conn.headers(),
|
|
52
|
+
rejectUnauthorized: this.conn.verifyTls,
|
|
53
|
+
handshakeTimeout: this.conn.timeoutMs,
|
|
54
|
+
});
|
|
55
|
+
ws.once('open', () => {
|
|
56
|
+
this.ws = ws;
|
|
57
|
+
resolve(ws);
|
|
58
|
+
});
|
|
59
|
+
ws.once('unexpected-response', (_req, res) => {
|
|
60
|
+
const denied = res.statusCode === 401 || res.statusCode === 403;
|
|
61
|
+
reject(
|
|
62
|
+
new PluginError(
|
|
63
|
+
denied ? ErrorCodes.UpstreamDenied : ErrorCodes.UpstreamError,
|
|
64
|
+
denied ? 'The upstream denied the connection: the credentials were rejected' : `The upstream refused the connection (HTTP ${res.statusCode})`,
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
ws.terminate();
|
|
68
|
+
});
|
|
69
|
+
ws.once('error', () =>
|
|
70
|
+
reject(new PluginError(ErrorCodes.UpstreamError, `The upstream is unreachable at ${new URL(url).host}`)),
|
|
71
|
+
);
|
|
72
|
+
ws.on('close', () => {
|
|
73
|
+
if (this.ws === ws) this.ws = undefined;
|
|
74
|
+
this.pending.failAll(new PluginError(ErrorCodes.UpstreamError, 'The upstream closed the connection'));
|
|
75
|
+
});
|
|
76
|
+
ws.on('message', (data) => {
|
|
77
|
+
let msg: { id?: unknown; result?: unknown; error?: RpcError };
|
|
78
|
+
try {
|
|
79
|
+
msg = JSON.parse(data.toString()) as typeof msg;
|
|
80
|
+
} catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const p = this.pending.take(msg.id);
|
|
84
|
+
if (!p) return;
|
|
85
|
+
if (!msg.error) return p.resolve(msg.result ?? null);
|
|
86
|
+
const code =
|
|
87
|
+
msg.error.code === -32601
|
|
88
|
+
? ErrorCodes.UnknownOperation
|
|
89
|
+
: msg.error.code === -32602
|
|
90
|
+
? ErrorCodes.InvalidParams
|
|
91
|
+
: ErrorCodes.UpstreamError;
|
|
92
|
+
p.reject(new PluginError(code, `${p.label}: ${msg.error.message ?? 'error'}`));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
baseKey,
|
|
3
|
+
compileRules,
|
|
4
|
+
definePlugin,
|
|
5
|
+
ErrorCodes,
|
|
6
|
+
isPlainObject,
|
|
7
|
+
parsePluginSettings,
|
|
8
|
+
PluginError,
|
|
9
|
+
requireString,
|
|
10
|
+
staticCatalog,
|
|
11
|
+
stringOr,
|
|
12
|
+
truncate,
|
|
13
|
+
} from '@synoikia/plugin-sdk';
|
|
14
|
+
import type { PluginHandlers } from '@synoikia/plugin-sdk';
|
|
15
|
+
import raw from '../plugin.yaml';
|
|
16
|
+
import { authHeaders } from './auth.js';
|
|
17
|
+
import { RpcClient } from './client.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The {{name}} plugin: JSON-RPC methods declared in plugin.yaml. The sandbox calls
|
|
21
|
+
* `{{namespace}}.call(method, params)`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SERVICE = '{{name}}';
|
|
25
|
+
|
|
26
|
+
export const settings = parsePluginSettings(raw, {
|
|
27
|
+
parse(value) {
|
|
28
|
+
const p = isPlainObject(value) ? value : {};
|
|
29
|
+
if (typeof p.path !== 'string') throw new Error('plugin.yaml plugin.path must be a string');
|
|
30
|
+
return { path: p.path };
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export function create{{Pascal}}Plugin(): PluginHandlers {
|
|
35
|
+
return definePlugin({
|
|
36
|
+
connect: (init) =>
|
|
37
|
+
new RpcClient({
|
|
38
|
+
baseUrl: requireString(init.config, 'baseUrl'),
|
|
39
|
+
path: settings.plugin.path,
|
|
40
|
+
verifyTls: init.config.verifyTls !== false,
|
|
41
|
+
headers: authHeaders(init),
|
|
42
|
+
timeoutMs: settings.defaults.timeouts.request,
|
|
43
|
+
}),
|
|
44
|
+
close: (client) => client.close(),
|
|
45
|
+
|
|
46
|
+
async version(kit) {
|
|
47
|
+
const info = await kit.client().call('system.info', {});
|
|
48
|
+
const version = isPlainObject(info) ? stringOr(info.version) : undefined;
|
|
49
|
+
if (!version) throw new PluginError(ErrorCodes.UpstreamError, `${SERVICE} reported no version`);
|
|
50
|
+
return version;
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
handlers(kit) {
|
|
54
|
+
const rules = compileRules(settings, {
|
|
55
|
+
// Confirmation lookups may only call declared reads.
|
|
56
|
+
lookup: (op, args, timeoutMs) => {
|
|
57
|
+
if (settings.operations.find((o) => o.key === op)?.classification !== 'read')
|
|
58
|
+
throw new PluginError(ErrorCodes.InvalidParams, `plugin.yaml lookup ${op} is not a read`);
|
|
59
|
+
return kit.client().call(op, args[0] ?? {}, timeoutMs);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const operations = staticCatalog(rules);
|
|
63
|
+
const known = new Set(settings.operations.map((o) => o.key));
|
|
64
|
+
return {
|
|
65
|
+
async syncCatalog() {
|
|
66
|
+
return { upstreamVersion: await kit.version(), operations };
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
resolveOperation({ fn, args }) {
|
|
70
|
+
if (fn !== 'call')
|
|
71
|
+
throw new PluginError(ErrorCodes.UnknownOperation, `{{namespace}}.${fn} is not a binding function`);
|
|
72
|
+
const [method, params] = args;
|
|
73
|
+
if (typeof method !== 'string' || !known.has(method))
|
|
74
|
+
throw new PluginError(ErrorCodes.UnknownOperation, `${String(method)} is not a ${SERVICE} method`);
|
|
75
|
+
if (params !== undefined && !isPlainObject(params))
|
|
76
|
+
throw new PluginError(ErrorCodes.InvalidParams, 'params must be an object');
|
|
77
|
+
// A split twin declared in plugin.yaml is locked; take it unless code here decides the call is safe.
|
|
78
|
+
const split = rules.splits(method)[0];
|
|
79
|
+
return { key: split ? `${method}#${split}` : method, params: params ?? {} };
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async summarize({ key, params, targets }) {
|
|
83
|
+
const json = JSON.stringify(params ?? {});
|
|
84
|
+
const notes = rules.summaryNotes(key).map((n) => ` ${n}`).join('');
|
|
85
|
+
const text = `${SERVICE} ${key}(${json === '{}' ? '' : truncate(json, 400)})${notes}`;
|
|
86
|
+
const literal = await rules.confirmLiteral({ key, params, targets });
|
|
87
|
+
return literal ? { text, confirmLiteral: literal } : { text };
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async invoke({ key, params, context }) {
|
|
91
|
+
const method = baseKey(key);
|
|
92
|
+
if (!known.has(method)) throw new PluginError(ErrorCodes.UnknownOperation, `${method} is not a ${SERVICE} method`);
|
|
93
|
+
return rules.maskResult(key, await kit.client().call(method, params, kit.timeout(context)));
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { checkPluginContract, startPluginHarness } from '@synoikia/core/testing';
|
|
4
|
+
import type { PluginHarness } from '@synoikia/core/testing';
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
6
|
+
import { CREDENTIALS } from './fake-auth.js';
|
|
7
|
+
import { startFake{{Pascal}}, UPSTREAM_SECRET } from './fake-upstream.js';
|
|
8
|
+
import type { Fake{{Pascal}} } from './fake-upstream.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The real Synoikia core running this plugin's built bundle (a permission-confined child, as in
|
|
12
|
+
* production) against the fake {{name}}, through core's plugin harness.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const PLUGIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
|
|
17
|
+
let h: PluginHarness;
|
|
18
|
+
let fake: Fake{{Pascal}};
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
fake = await startFake{{Pascal}}();
|
|
22
|
+
h = await startPluginHarness({ pluginDir: PLUGIN_DIR, connection: { baseUrl: fake.url, ...CREDENTIALS } });
|
|
23
|
+
}, 60_000);
|
|
24
|
+
|
|
25
|
+
afterAll(async () => {
|
|
26
|
+
await h?.stop();
|
|
27
|
+
await fake?.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('{{name}} end to end (fake {{name}})', () => {
|
|
31
|
+
it('keeps the plugin contract', async () => {
|
|
32
|
+
expect(
|
|
33
|
+
await checkPluginContract(h, {
|
|
34
|
+
read: { key: 'items.list', code: `return await {{namespace}}.call('items.list');` },
|
|
35
|
+
write: { key: 'items.create', code: `return await {{namespace}}.call('items.create', { name: 'x' });` },
|
|
36
|
+
locked: {
|
|
37
|
+
key: 'items.delete',
|
|
38
|
+
code: `return await {{namespace}}.call('items.delete', { id: 1 });`,
|
|
39
|
+
confirm: 'First item',
|
|
40
|
+
},
|
|
41
|
+
secrets: { code: `return await {{namespace}}.call('settings.get');`, values: [UPSTREAM_SECRET] },
|
|
42
|
+
}),
|
|
43
|
+
).toEqual([]);
|
|
44
|
+
expect(fake.items.has(1)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AddressInfo } from 'node:net';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { authorized } from './fake-auth.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A fake {{name}} speaking JSON-RPC 2.0 over WebSocket: just enough to exercise the plugin, including
|
|
7
|
+
* a response that holds a secret, so the tests prove it never reaches the sandbox.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const UPSTREAM_SECRET = 'fake-upstream-secret-123';
|
|
11
|
+
|
|
12
|
+
export interface Fake{{Pascal}} {
|
|
13
|
+
url: string;
|
|
14
|
+
items: Map<number, { id: number; name: string }>;
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function startFake{{Pascal}}(): Promise<Fake{{Pascal}}> {
|
|
19
|
+
const items = new Map([[1, { id: 1, name: 'First item' }]]);
|
|
20
|
+
let next = 2;
|
|
21
|
+
const methods: Record<string, (p: Record<string, unknown>) => unknown> = {
|
|
22
|
+
'system.info': () => ({ name: 'Home {{name}}', version: '1.2.3' }),
|
|
23
|
+
'items.list': () => [...items.values()],
|
|
24
|
+
'items.get': (p) => items.get(Number(p.id)) ?? null,
|
|
25
|
+
'items.create': (p) => {
|
|
26
|
+
const item = { id: next++, name: String(p.name ?? 'Unnamed') };
|
|
27
|
+
items.set(item.id, item);
|
|
28
|
+
return item;
|
|
29
|
+
},
|
|
30
|
+
'items.delete': (p) => items.delete(Number(p.id)),
|
|
31
|
+
'settings.get': () => ({ title: 'Home', apiKey: UPSTREAM_SECRET }),
|
|
32
|
+
};
|
|
33
|
+
const wss = new WebSocketServer({
|
|
34
|
+
port: 0,
|
|
35
|
+
host: '127.0.0.1',
|
|
36
|
+
path: '/rpc',
|
|
37
|
+
verifyClient: ({ req }: { req: { headers: Record<string, string | string[] | undefined> } }) =>
|
|
38
|
+
authorized(req.headers),
|
|
39
|
+
});
|
|
40
|
+
wss.on('connection', (ws) => {
|
|
41
|
+
ws.on('message', (data) => {
|
|
42
|
+
const msg = JSON.parse(data.toString()) as { id: number; method: string; params?: Record<string, unknown> };
|
|
43
|
+
const fn = methods[msg.method];
|
|
44
|
+
ws.send(
|
|
45
|
+
JSON.stringify(
|
|
46
|
+
fn
|
|
47
|
+
? { jsonrpc: '2.0', id: msg.id, result: fn(msg.params ?? {}) }
|
|
48
|
+
: { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not found' } },
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
await new Promise<void>((resolve) => wss.once('listening', resolve));
|
|
54
|
+
return {
|
|
55
|
+
url: `http://127.0.0.1:${(wss.address() as AddressInfo).port}`,
|
|
56
|
+
items,
|
|
57
|
+
close: () =>
|
|
58
|
+
new Promise((resolve) => {
|
|
59
|
+
for (const c of wss.clients) c.terminate();
|
|
60
|
+
wss.close(() => resolve());
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { checkConformance, ErrorCodes } from '@synoikia/plugin-sdk';
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
4
|
+
import { create{{Pascal}}Plugin, settings } from '../src/plugin.js';
|
|
5
|
+
import { CREDENTIALS } from './fake-auth.js';
|
|
6
|
+
import { startFake{{Pascal}} } from './fake-upstream.js';
|
|
7
|
+
import type { Fake{{Pascal}} } from './fake-upstream.js';
|
|
8
|
+
|
|
9
|
+
const manifest: unknown = JSON.parse(readFileSync(new URL('../manifest.json', import.meta.url), 'utf8'));
|
|
10
|
+
|
|
11
|
+
let fake: Fake{{Pascal}};
|
|
12
|
+
const plugin = create{{Pascal}}Plugin();
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
fake = await startFake{{Pascal}}();
|
|
16
|
+
});
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await plugin.shutdown?.();
|
|
19
|
+
await fake?.close();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const connection = () => {
|
|
23
|
+
const { username, ...secrets } = CREDENTIALS as Record<string, string>;
|
|
24
|
+
return { instanceId: '{{id}}', config: { baseUrl: fake.url, ...(username ? { username } : {}) }, secrets };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('{{name}} plugin', () => {
|
|
28
|
+
it('passes the SDK conformance checks against the fake', async () => {
|
|
29
|
+
const issues = await checkConformance({
|
|
30
|
+
manifest,
|
|
31
|
+
settings,
|
|
32
|
+
handlers: plugin,
|
|
33
|
+
init: connection(),
|
|
34
|
+
samples: [
|
|
35
|
+
{ fn: 'call', args: ['items.list'], expectKey: 'items.list' },
|
|
36
|
+
{ fn: 'call', args: ['items.delete', { id: 1 }], expectKey: 'items.delete' },
|
|
37
|
+
],
|
|
38
|
+
rejects: [{ fn: 'call', args: ['items.nope'] }, { fn: 'other', args: [] }],
|
|
39
|
+
});
|
|
40
|
+
expect(issues).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('locks deletes and names the item in the confirmation', async () => {
|
|
44
|
+
await plugin.init({ ...connection(), sdkVersion: '1' });
|
|
45
|
+
const { operations } = await plugin.syncCatalog();
|
|
46
|
+
expect(operations.find((o) => o.key === 'items.delete')).toMatchObject({ locked: true });
|
|
47
|
+
expect(await plugin.summarize({ key: 'items.delete', params: { id: 1 }, targets: [] })).toEqual({
|
|
48
|
+
text: '{{name}} items.delete({"id":1})',
|
|
49
|
+
confirmLiteral: 'First item',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('reports rejected credentials without throwing', async () => {
|
|
54
|
+
const wrong = Object.fromEntries(Object.keys(connection().secrets).map((k) => [k, 'wrong']));
|
|
55
|
+
if (!Object.keys(wrong).length) return; // nothing to reject without authentication
|
|
56
|
+
const bad = create{{Pascal}}Plugin();
|
|
57
|
+
await bad.init({ ...connection(), secrets: wrong, sdkVersion: '1' });
|
|
58
|
+
expect(await bad.testConnection()).toMatchObject({ ok: false, message: expect.stringContaining('denied') });
|
|
59
|
+
await bad.shutdown?.();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('refuses a connection without a base URL', async () => {
|
|
63
|
+
await expect(create{{Pascal}}Plugin().init({ ...connection(), config: {}, sdkVersion: '1' })).rejects.toMatchObject({
|
|
64
|
+
code: ErrorCodes.InvalidParams,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# {{repoName}}
|
|
2
|
+
|
|
3
|
+
Plugins for [Synoikia](https://github.com/via-justa/synoikia-core), the self-hosted MCP control plane.
|
|
4
|
+
|
|
5
|
+
A plugin only describes its upstream API: the operations it has, which ones read and which ones write, which are destructive, and which fields hold secrets. Synoikia's core decides what is allowed and runs every call through its sandbox, permission gate, approvals, redaction and audit log. See the [plugin authoring guide](https://github.com/via-justa/synoikia-core/blob/main/docs/plugin-authoring.md).
|
|
6
|
+
|
|
7
|
+
## Layout
|
|
8
|
+
|
|
9
|
+
| Path | What |
|
|
10
|
+
| ---------------------------- | ---------------------------------------------------------------------------------------- |
|
|
11
|
+
| `plugins/<id>/manifest.json` | Binding, connection form, `sensitiveKeys`, network hosts |
|
|
12
|
+
| `plugins/<id>/plugin.yaml` | What the plugin tells core about its operations: locks, splits, secrets, confirm literals |
|
|
13
|
+
| `plugins/<id>/src/` | Discovery, auth and anything that needs logic |
|
|
14
|
+
| `plugins/<id>/test/` | A fake upstream, unit tests and e2e tests on Synoikia's harness |
|
|
15
|
+
|
|
16
|
+
## Development
|
|
17
|
+
|
|
18
|
+
Requires Node ≥ 22.12 and pnpm 10 (`corepack enable`).
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm install
|
|
22
|
+
pnpm new # add a plugin
|
|
23
|
+
pnpm test # check, build and test every plugin
|
|
24
|
+
pnpm typecheck && pnpm lint && pnpm format:check
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Work on one plugin with `pnpm --filter ./plugins/<id> test`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
check:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: pnpm/action-setup@v4
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version-file: .nvmrc
|
|
20
|
+
cache: pnpm
|
|
21
|
+
- run: pnpm install --frozen-lockfile
|
|
22
|
+
- run: pnpm lint
|
|
23
|
+
- run: pnpm format:check
|
|
24
|
+
- run: pnpm typecheck
|
|
25
|
+
# Each plugin's test checks its manifest and plugin.yaml, builds its bundle, then runs its unit tests and
|
|
26
|
+
# its e2e tests on core's harness under the permission model.
|
|
27
|
+
- run: pnpm test
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
22
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import tseslint from 'typescript-eslint';
|
|
3
|
+
|
|
4
|
+
export default tseslint.config(
|
|
5
|
+
{ ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**', '.repo/**', '.repo-current/**'] },
|
|
6
|
+
js.configs.recommended,
|
|
7
|
+
...tseslint.configs.recommended,
|
|
8
|
+
{
|
|
9
|
+
files: ['**/*.mjs'],
|
|
10
|
+
languageOptions: { globals: { console: 'readonly', process: 'readonly' } },
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
rules: {
|
|
14
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{repoName}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"packageManager": "pnpm@10.33.0",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.12.0"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"new": "synoikia-plugin new",
|
|
11
|
+
"build": "pnpm -r --filter './plugins/*' build",
|
|
12
|
+
"check": "synoikia-plugin check",
|
|
13
|
+
"typecheck": "pnpm -r --filter './plugins/*' typecheck",
|
|
14
|
+
"test": "pnpm -r --filter './plugins/*' test",
|
|
15
|
+
"lint": "eslint .",
|
|
16
|
+
"format": "prettier --write .",
|
|
17
|
+
"format:check": "prettier --check ."
|
|
18
|
+
},
|
|
19
|
+
"synoikia": {
|
|
20
|
+
"repository": "{{repository}}",
|
|
21
|
+
"name": "{{repoName}}"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@eslint/js": "^10.0.1",
|
|
25
|
+
"@synoikia/core": "{{coreRange}}",
|
|
26
|
+
"@synoikia/create-plugin": "{{cliRange}}",
|
|
27
|
+
"@types/node": "^22.19.0",
|
|
28
|
+
"eslint": "^10.11.0",
|
|
29
|
+
"prettier": "^3.6.2",
|
|
30
|
+
"typescript": "~6.0.3",
|
|
31
|
+
"typescript-eslint": "^8.70.1",
|
|
32
|
+
"vitest": "^5.0.2"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
packages:
|
|
2
|
+
- plugins/*
|
|
3
|
+
|
|
4
|
+
onlyBuiltDependencies:
|
|
5
|
+
- esbuild
|
|
6
|
+
# @synoikia/core (the plugin harness) runs isolated-vm; node-gyp-build picks the shipped prebuild.
|
|
7
|
+
- isolated-vm
|
|
8
|
+
|
|
9
|
+
# better-sqlite3 ships prebuilt N-API binaries for all supported platforms; skip its node-gyp fallback.
|
|
10
|
+
ignoredBuiltDependencies:
|
|
11
|
+
- better-sqlite3
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noImplicitOverride": true,
|
|
10
|
+
"exactOptionalPropertyTypes": false,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"sourceMap": true,
|
|
19
|
+
"types": ["node"]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { pluginFiles } from '@synoikia/create-plugin/vitest';
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
// plugin.yaml and guides load as the bundle sees them.
|
|
6
|
+
plugins: [pluginFiles()],
|
|
7
|
+
// The plugin harness (@synoikia/core/testing) runs isolated-vm, which requires --no-node-snapshot on Node >= 20.
|
|
8
|
+
test: { include: ['test/**/*.test.ts'], execArgv: ['--no-node-snapshot'] },
|
|
9
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# {{repoName}}
|
|
2
|
+
|
|
3
|
+
Plugins for [Synoikia](https://github.com/via-justa/synoikia-core). Authoring guide: `https://github.com/via-justa/synoikia-core/blob/main/docs/plugin-authoring.md`.
|
|
4
|
+
|
|
5
|
+
**A plugin only describes its upstream API; core decides what is allowed.** Core's sandbox, permission gate, approvals, redaction and audit apply to every plugin. A plugin supplies the catalog, the classification (read, write, locked), targets, summaries, confirmation literals and `sensitiveKeys`. It never adds its own allow/deny switch, approval prompt or secret handling.
|
|
6
|
+
|
|
7
|
+
## Layout
|
|
8
|
+
|
|
9
|
+
| Path | What |
|
|
10
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------ |
|
|
11
|
+
| `plugins/<id>/manifest.json` | Validated by the SDK schema: binding, connection form, `sensitiveKeys`, network hosts |
|
|
12
|
+
| `plugins/<id>/plugin.yaml` | Policy as data: locked operations, split twins, sensitive params/results, confirm literals |
|
|
13
|
+
| `plugins/<id>/src/` | `index.ts` calls `runPlugin()`; `plugin.ts` holds discovery and logic; `auth.ts` the auth |
|
|
14
|
+
| `plugins/<id>/test/` | A fake upstream, unit tests, and `e2e.test.ts` on core's harness |
|
|
15
|
+
|
|
16
|
+
Policy data (locks, splits, sensitive params and results, confirm literals, guidance) goes in `plugin.yaml`; only decisions that need logic go in `src/`. `plugin.yaml` is validated and inlined at build time, so rebuild before e2e tests.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
Node 22.12+, pnpm 10.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm new # add a plugin (see the add-plugin skill)
|
|
24
|
+
pnpm lint && pnpm format:check && pnpm typecheck && pnpm test # what CI runs
|
|
25
|
+
pnpm --filter ./plugins/<id> test # one plugin: check, build, unit + e2e
|
|
26
|
+
pnpm check # manifests, plugin.yaml and versions
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Rules that break releases or installs
|
|
30
|
+
|
|
31
|
+
- **Self-contained bundle.** The plugin reads nothing outside its own package directory at runtime; `synoikia-plugin build` bundles every dependency and data file.
|
|
32
|
+
- **Network hosts.** The plugin reaches only the hosts in `manifest.json` `network.hosts`.
|
|
33
|
+
- **Secrets.** Every credential field is `writeOnly: true`, uses the `secret` widget and is in `sensitiveKeys`, along with every secret field the upstream returns. `synoikia-plugin check` enforces the first part; an e2e `secrets` contract check covers the second.
|
|
34
|
+
- **Versions.** `version` in `manifest.json` and `package.json` must match, and a released version never changes. Bump only when asked; the `release-plugin` skill covers it.
|
|
35
|
+
|
|
36
|
+
## Security review
|
|
37
|
+
|
|
38
|
+
Before opening a PR that touches a plugin's `manifest.json`, `plugin.yaml`, `src/` or dependencies, or `.github/workflows/`, run the `security-reviewer` subagent on the diff and address its findings.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security-reviewer
|
|
3
|
+
description: Reviews a change to a Synoikia plugin, or to this repository's release workflow, for security problems and known CVEs in its dependencies. Use proactively before opening or merging a PR that touches plugins/*/manifest.json, plugins/*/plugin.yaml, plugins/*/src, a plugin's dependencies or .github/workflows; or when asked for a security review or dependency audit.
|
|
4
|
+
tools: Read, Grep, Glob, Bash
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You review changes to Synoikia plugins for security problems. You are read-only: never edit files, commit, push or change the environment. Use Bash only for read-only commands (`git diff`, `git log`, `pnpm audit`, `pnpm why`, `pnpm ls`) and for running existing tests.
|
|
8
|
+
|
|
9
|
+
## The trust model
|
|
10
|
+
|
|
11
|
+
Synoikia core runs every plugin as a permission-confined child and puts every call through its own sandbox, permission gate, human approvals, redaction and audit log. Core enforces policy based on what the plugin tells it. A plugin that labels a destructive call `read`, leaves a secret out of `sensitiveKeys`, or writes a misleading approval summary weakens every install, and core can't detect it. Most findings are cases where the plugin gave core wrong information.
|
|
12
|
+
|
|
13
|
+
## Scope
|
|
14
|
+
|
|
15
|
+
Start from the diff you were given, otherwise `git diff origin/main...HEAD` plus uncommitted changes. Read the plugin's `manifest.json` and `plugin.yaml` in full whenever its `src/` changes.
|
|
16
|
+
|
|
17
|
+
## What to check
|
|
18
|
+
|
|
19
|
+
- **Classification** (`plugin.yaml` rules, discovery in `src/`): no operation with side effects is labeled read, including GETs that act. Destructive or hard-to-undo operations are `locked`. Unknown operations fail closed. A `classification: read` override needs a reason; removing a `locked`, `sensitiveParams` or `sensitiveResult` rule, or widening `include` past an `exclude`, is a finding unless the PR justifies it. `splitWhen` predicates must fail closed (unknown means the locked twin).
|
|
20
|
+
- **Request building**: model-supplied arguments cannot change the host, scheme or port, path parameters are encoded, model arguments cannot set auth headers, and the operation key core gated is the one invoked.
|
|
21
|
+
- **Summaries and confirm literals**: the approval shows exactly what will be affected; a literal names the thing at risk, and crafted params can't make it misleading. Summaries never contain secrets.
|
|
22
|
+
- **Secrets**: every credential field is `writeOnly`, uses the `secret` widget and is in `sensitiveKeys`; every secret field the upstream returns is in `sensitiveKeys` or a `sensitiveResult` rule, with an e2e check. Credentials never appear in errors, logs or catalog text, and are never sent to any host but the upstream.
|
|
23
|
+
- **Network**: `network.hosts` is minimal; TLS verification is on by default.
|
|
24
|
+
- **Staying inside core's model**: no plugin-side allow/deny lists, approval prompts or caching that serves data after core denied a call; no `eval`, `new Function` or dynamic imports; nothing loaded from disk at runtime (`plugin.yaml` is inlined at build time).
|
|
25
|
+
- **Untrusted upstream data**: specs, JSON and frames are untrusted; look for prototype pollution, unbounded sizes and YAML beyond plain data.
|
|
26
|
+
|
|
27
|
+
## Known vulnerabilities
|
|
28
|
+
|
|
29
|
+
Run `pnpm audit --json` and `pnpm audit --prod`. For each advisory, `pnpm why <package>`: a plugin's `dependencies` are bundled and ship to every install, so judge reachability; `devDependencies` run only in CI. Give the advisory ID, affected and patched ranges, the dependency path and the smallest fix. If the audit can't reach the registry, say so.
|
|
30
|
+
|
|
31
|
+
## Reporting
|
|
32
|
+
|
|
33
|
+
Report only issues tied to concrete code or a concrete advisory: severity (critical, high, medium, low), location, what breaks, a scenario, and the smallest fix with the test that would catch a regression. If there are none, say so and list what you checked.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// PostToolUse hook: runs Prettier on the file just edited, so `pnpm format:check` in CI stays green.
|
|
2
|
+
// Prettier skips files in .prettierignore and unknown types; a failure here never blocks the edit.
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
let input = '';
|
|
8
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
9
|
+
const { tool_input: toolInput = {}, cwd } = JSON.parse(input || '{}');
|
|
10
|
+
const root = process.env.CLAUDE_PROJECT_DIR ?? cwd ?? process.cwd();
|
|
11
|
+
const prettier = path.join(root, 'node_modules/.bin/prettier');
|
|
12
|
+
const file = toolInput.file_path;
|
|
13
|
+
|
|
14
|
+
if (file && existsSync(file) && existsSync(prettier)) {
|
|
15
|
+
try {
|
|
16
|
+
execFileSync(prettier, ['--write', '--ignore-unknown', '--log-level=warn', file], { cwd: root, stdio: 'ignore' });
|
|
17
|
+
} catch {
|
|
18
|
+
// A file Prettier can't parse is left as written; lint and format:check will report it.
|
|
19
|
+
}
|
|
20
|
+
}
|