@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,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildOpenApiCatalog,
|
|
3
|
+
compileRules,
|
|
4
|
+
definePlugin,
|
|
5
|
+
ErrorCodes,
|
|
6
|
+
HttpJsonClient,
|
|
7
|
+
isPlainObject,
|
|
8
|
+
joinApiPath,
|
|
9
|
+
parsePluginSettings,
|
|
10
|
+
PluginError,
|
|
11
|
+
requireString,
|
|
12
|
+
restBinding,
|
|
13
|
+
restLookup,
|
|
14
|
+
stringOr,
|
|
15
|
+
} from '@synoikia/plugin-sdk';
|
|
16
|
+
import type { PluginHandlers } from '@synoikia/plugin-sdk';
|
|
17
|
+
import raw from '../plugin.yaml';
|
|
18
|
+
import { authHeaders } from './auth.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The {{name}} plugin: the catalog is {{name}}'s OpenAPI spec, and the sandbox calls
|
|
22
|
+
* `{{namespace}}.request({ method, path, query, body })`. Locks and secrets are in plugin.yaml.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const SERVICE = '{{name}}';
|
|
26
|
+
|
|
27
|
+
export const settings = parsePluginSettings(raw, {
|
|
28
|
+
parse(value) {
|
|
29
|
+
const p = isPlainObject(value) ? value : {};
|
|
30
|
+
const text = (key: string) => {
|
|
31
|
+
if (typeof p[key] !== 'string') throw new Error(`plugin.yaml plugin.${key} must be a string`);
|
|
32
|
+
return p[key];
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
apiPath: text('apiPath'),
|
|
36
|
+
specPath: text('specPath'),
|
|
37
|
+
versionPath: text('versionPath'),
|
|
38
|
+
versionField: text('versionField'),
|
|
39
|
+
minOperations: typeof p.minOperations === 'number' ? p.minOperations : 1,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export function create{{Pascal}}Plugin(): PluginHandlers {
|
|
45
|
+
return definePlugin({
|
|
46
|
+
connect: (init) =>
|
|
47
|
+
new HttpJsonClient({
|
|
48
|
+
baseUrl: joinApiPath(requireString(init.config, 'baseUrl'), settings.plugin.apiPath),
|
|
49
|
+
service: SERVICE,
|
|
50
|
+
verifyTls: init.config.verifyTls !== false,
|
|
51
|
+
headers: authHeaders(init),
|
|
52
|
+
timeoutMs: settings.defaults.timeouts.request,
|
|
53
|
+
}),
|
|
54
|
+
|
|
55
|
+
async version(kit) {
|
|
56
|
+
const status = await kit.client().request('GET', settings.plugin.versionPath);
|
|
57
|
+
const version = isPlainObject(status) ? stringOr(status[settings.plugin.versionField]) : undefined;
|
|
58
|
+
if (!version) throw new PluginError(ErrorCodes.UpstreamError, `${SERVICE} reported no version`);
|
|
59
|
+
return version;
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
handlers(kit) {
|
|
63
|
+
const rules = compileRules(settings, { lookup: restLookup(kit.client) });
|
|
64
|
+
const catalog = kit.lazy(async () => {
|
|
65
|
+
const spec = await kit.client().request('GET', settings.plugin.specPath);
|
|
66
|
+
if (typeof spec !== 'string' && !isPlainObject(spec))
|
|
67
|
+
throw new PluginError(ErrorCodes.UpstreamError, `${SERVICE} served no OpenAPI spec`);
|
|
68
|
+
return buildOpenApiCatalog(spec, { service: SERVICE, rules, minOperations: settings.plugin.minOperations });
|
|
69
|
+
});
|
|
70
|
+
const binding = restBinding({
|
|
71
|
+
namespace: '{{namespace}}',
|
|
72
|
+
service: SERVICE,
|
|
73
|
+
rules,
|
|
74
|
+
catalog: catalog.get,
|
|
75
|
+
client: kit.client,
|
|
76
|
+
stripPrefix: settings.plugin.apiPath,
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
async syncCatalog() {
|
|
80
|
+
const upstreamVersion = await kit.version();
|
|
81
|
+
return { upstreamVersion, operations: (await catalog.reload()).operations };
|
|
82
|
+
},
|
|
83
|
+
resolveOperation: binding.resolveOperation,
|
|
84
|
+
summarize: binding.summarize,
|
|
85
|
+
invoke: (params) => binding.invoke(params),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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: 'GET /items', code: `return await {{namespace}}.request({ path: '/items' });` },
|
|
35
|
+
write: {
|
|
36
|
+
key: 'POST /items',
|
|
37
|
+
code: `return await {{namespace}}.request({ method: 'POST', path: '/items', body: { name: 'x' } });`,
|
|
38
|
+
},
|
|
39
|
+
locked: {
|
|
40
|
+
key: 'DELETE /items/{itemId}',
|
|
41
|
+
code: `return await {{namespace}}.request({ method: 'DELETE', path: '/items/1' });`,
|
|
42
|
+
confirm: 'First item',
|
|
43
|
+
},
|
|
44
|
+
secrets: { code: `return await {{namespace}}.request({ path: '/settings' });`, values: [UPSTREAM_SECRET] },
|
|
45
|
+
}),
|
|
46
|
+
).toEqual([]);
|
|
47
|
+
expect(fake.items.has(1)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { startFakeHttp } from '@synoikia/core/testing';
|
|
2
|
+
import type { FakeHttp } from '@synoikia/core/testing';
|
|
3
|
+
import { authorized } from './fake-auth.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A fake {{name}}: just enough of its API to exercise the plugin, including a response that holds a
|
|
7
|
+
* secret, so the tests prove it never reaches the sandbox. Extend it as the plugin grows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const UPSTREAM_SECRET = 'fake-upstream-secret-123';
|
|
11
|
+
|
|
12
|
+
const SPEC = {
|
|
13
|
+
openapi: '3.0.3',
|
|
14
|
+
paths: {
|
|
15
|
+
'/status': { get: { summary: 'Version and name', tags: ['System'] } },
|
|
16
|
+
'/settings': { get: { summary: 'Settings, including an integration key', tags: ['Settings'] } },
|
|
17
|
+
'/items': {
|
|
18
|
+
get: { summary: 'List items', tags: ['Items'] },
|
|
19
|
+
post: {
|
|
20
|
+
summary: 'Create an item',
|
|
21
|
+
tags: ['Items'],
|
|
22
|
+
requestBody: {
|
|
23
|
+
content: { 'application/json': { schema: { type: 'object', properties: { name: { type: 'string' } } } } },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
'/items/{itemId}': {
|
|
28
|
+
parameters: [{ name: 'itemId', in: 'path', required: true, schema: { type: 'number' } }],
|
|
29
|
+
get: { summary: 'Get an item', tags: ['Items'] },
|
|
30
|
+
delete: { summary: 'Delete an item', tags: ['Items'] },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface Fake{{Pascal}} extends FakeHttp {
|
|
36
|
+
items: Map<number, { id: number; name: string }>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function startFake{{Pascal}}(): Promise<Fake{{Pascal}}> {
|
|
40
|
+
const items = new Map([[1, { id: 1, name: 'First item' }]]);
|
|
41
|
+
let next = 2;
|
|
42
|
+
const fake = await startFakeHttp({
|
|
43
|
+
guard: (req) => (authorized(req.headers) ? undefined : { status: 401, body: { message: 'Unauthorized' } }),
|
|
44
|
+
routes: {
|
|
45
|
+
'GET /api/status': () => ({ body: { version: '1.2.3', name: 'Home {{name}}' } }),
|
|
46
|
+
'GET /api/openapi.json': () => ({ body: SPEC }),
|
|
47
|
+
'GET /api/settings': () => ({ body: { title: 'Home', apiKey: UPSTREAM_SECRET } }),
|
|
48
|
+
'GET /api/items': () => ({ body: [...items.values()] }),
|
|
49
|
+
'POST /api/items': (req) => {
|
|
50
|
+
const item = { id: next++, name: String((req.body as { name?: unknown })?.name ?? 'Unnamed') };
|
|
51
|
+
items.set(item.id, item);
|
|
52
|
+
return { status: 201, body: item };
|
|
53
|
+
},
|
|
54
|
+
'GET /api/items/{itemId}': (req) => {
|
|
55
|
+
const item = items.get(Number(req.params.itemId));
|
|
56
|
+
return item ? { body: item } : { status: 404, body: { message: 'No such item' } };
|
|
57
|
+
},
|
|
58
|
+
'DELETE /api/items/{itemId}': (req) =>
|
|
59
|
+
items.delete(Number(req.params.itemId)) ? { status: 204 } : { status: 404, body: { message: 'No such item' } },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return { ...fake, items };
|
|
63
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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: 'request', args: [{ path: '/items' }], expectKey: 'GET /items' },
|
|
36
|
+
{ fn: 'request', args: [{ method: 'DELETE', path: '/items/1' }], expectKey: 'DELETE /items/{itemId}' },
|
|
37
|
+
],
|
|
38
|
+
rejects: [{ fn: 'request', args: [{ path: '/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 === 'DELETE /items/{itemId}')).toMatchObject({ locked: true });
|
|
47
|
+
expect(
|
|
48
|
+
await plugin.summarize({ key: 'DELETE /items/{itemId}', params: { path: { itemId: '1' } }, targets: [] }),
|
|
49
|
+
).toEqual({ text: '{{name}} DELETE /items/1', confirmLiteral: 'First item' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('reports rejected credentials without throwing', async () => {
|
|
53
|
+
const wrong = Object.fromEntries(Object.keys(connection().secrets).map((k) => [k, 'wrong']));
|
|
54
|
+
if (!Object.keys(wrong).length) return; // nothing to reject without authentication
|
|
55
|
+
const bad = create{{Pascal}}Plugin();
|
|
56
|
+
await bad.init({ ...connection(), secrets: wrong, sdkVersion: '1' });
|
|
57
|
+
expect(await bad.testConnection()).toMatchObject({ ok: false, message: expect.stringContaining('denied') });
|
|
58
|
+
await bad.shutdown?.();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('refuses a connection without a base URL', async () => {
|
|
62
|
+
await expect(create{{Pascal}}Plugin().init({ ...connection(), config: {}, sdkVersion: '1' })).rejects.toMatchObject({
|
|
63
|
+
code: ErrorCodes.InvalidParams,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# yaml-language-server: $schema=./node_modules/@synoikia/plugin-sdk/plugin-settings.schema.json
|
|
2
|
+
#
|
|
3
|
+
# What {{name}} tells Synoikia about its operations. With no spec to discover, every operation is
|
|
4
|
+
# declared here with its own classification; rules lock and describe them on top. Core decides what is
|
|
5
|
+
# allowed. Reference: docs/plugin-authoring.md.
|
|
6
|
+
|
|
7
|
+
defaults:
|
|
8
|
+
confirm: instance
|
|
9
|
+
timeouts: { request: 15000, lookup: 10000 }
|
|
10
|
+
|
|
11
|
+
instanceName: { op: 'GET /status', field: name, fallback: '{{name}}' }
|
|
12
|
+
|
|
13
|
+
operations:
|
|
14
|
+
- key: items.list
|
|
15
|
+
group: items
|
|
16
|
+
classification: read
|
|
17
|
+
summary: List items.
|
|
18
|
+
http: { method: GET, path: /items }
|
|
19
|
+
- key: items.get
|
|
20
|
+
group: items
|
|
21
|
+
classification: read
|
|
22
|
+
summary: One item.
|
|
23
|
+
http: { method: GET, path: '/items/{itemId}' }
|
|
24
|
+
paramsSchema:
|
|
25
|
+
type: object
|
|
26
|
+
properties: { path: { type: object, properties: { itemId: { type: string } }, required: [itemId] } }
|
|
27
|
+
- key: items.create
|
|
28
|
+
group: items
|
|
29
|
+
classification: write
|
|
30
|
+
summary: Create an item.
|
|
31
|
+
http: { method: POST, path: /items }
|
|
32
|
+
paramsSchema:
|
|
33
|
+
type: object
|
|
34
|
+
properties: { body: { type: object, properties: { name: { type: string } }, required: [name] } }
|
|
35
|
+
- key: items.delete
|
|
36
|
+
group: items
|
|
37
|
+
classification: write
|
|
38
|
+
summary: Delete an item.
|
|
39
|
+
http: { method: DELETE, path: '/items/{itemId}' }
|
|
40
|
+
- key: settings.get
|
|
41
|
+
group: settings
|
|
42
|
+
classification: read
|
|
43
|
+
summary: Settings, including an integration key.
|
|
44
|
+
http: { method: GET, path: /settings }
|
|
45
|
+
|
|
46
|
+
rules:
|
|
47
|
+
# Destructive or irreversible: always a human who types the confirmation literal.
|
|
48
|
+
- match: items.delete
|
|
49
|
+
locked: true
|
|
50
|
+
confirm:
|
|
51
|
+
lookup:
|
|
52
|
+
op: 'GET /items/{itemId}'
|
|
53
|
+
args: [{ itemId: { $param: /path/itemId } }]
|
|
54
|
+
field: name
|
|
55
|
+
|
|
56
|
+
plugin:
|
|
57
|
+
# Path of the API under the base URL.
|
|
58
|
+
apiPath: /api
|
|
59
|
+
# Endpoint and field that report the version.
|
|
60
|
+
versionPath: /status
|
|
61
|
+
versionField: version
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
compileRules,
|
|
3
|
+
definePlugin,
|
|
4
|
+
ErrorCodes,
|
|
5
|
+
HttpJsonClient,
|
|
6
|
+
isPlainObject,
|
|
7
|
+
joinApiPath,
|
|
8
|
+
parsePluginSettings,
|
|
9
|
+
PluginError,
|
|
10
|
+
requireString,
|
|
11
|
+
restLookup,
|
|
12
|
+
staticCatalog,
|
|
13
|
+
staticHttpBinding,
|
|
14
|
+
stringOr,
|
|
15
|
+
} from '@synoikia/plugin-sdk';
|
|
16
|
+
import type { PluginHandlers } from '@synoikia/plugin-sdk';
|
|
17
|
+
import raw from '../plugin.yaml';
|
|
18
|
+
import { authHeaders } from './auth.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The {{name}} plugin: operations are declared in plugin.yaml, and the sandbox calls
|
|
22
|
+
* `{{namespace}}.call(operation, { path, query, body })`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const SERVICE = '{{name}}';
|
|
26
|
+
|
|
27
|
+
export const settings = parsePluginSettings(raw, {
|
|
28
|
+
parse(value) {
|
|
29
|
+
const p = isPlainObject(value) ? value : {};
|
|
30
|
+
const text = (key: string) => {
|
|
31
|
+
if (typeof p[key] !== 'string') throw new Error(`plugin.yaml plugin.${key} must be a string`);
|
|
32
|
+
return p[key];
|
|
33
|
+
};
|
|
34
|
+
return { apiPath: text('apiPath'), versionPath: text('versionPath'), versionField: text('versionField') };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export function create{{Pascal}}Plugin(): PluginHandlers {
|
|
39
|
+
return definePlugin({
|
|
40
|
+
connect: (init) =>
|
|
41
|
+
new HttpJsonClient({
|
|
42
|
+
baseUrl: joinApiPath(requireString(init.config, 'baseUrl'), settings.plugin.apiPath),
|
|
43
|
+
service: SERVICE,
|
|
44
|
+
verifyTls: init.config.verifyTls !== false,
|
|
45
|
+
headers: authHeaders(init),
|
|
46
|
+
timeoutMs: settings.defaults.timeouts.request,
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
async version(kit) {
|
|
50
|
+
const status = await kit.client().request('GET', settings.plugin.versionPath);
|
|
51
|
+
const version = isPlainObject(status) ? stringOr(status[settings.plugin.versionField]) : undefined;
|
|
52
|
+
if (!version) throw new PluginError(ErrorCodes.UpstreamError, `${SERVICE} reported no version`);
|
|
53
|
+
return version;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
handlers(kit) {
|
|
57
|
+
const rules = compileRules(settings, { lookup: restLookup(kit.client) });
|
|
58
|
+
const operations = staticCatalog(rules);
|
|
59
|
+
const binding = staticHttpBinding({ namespace: '{{namespace}}', service: SERVICE, rules, client: kit.client });
|
|
60
|
+
return {
|
|
61
|
+
async syncCatalog() {
|
|
62
|
+
return { upstreamVersion: await kit.version(), operations };
|
|
63
|
+
},
|
|
64
|
+
resolveOperation: binding.resolveOperation,
|
|
65
|
+
summarize: binding.summarize,
|
|
66
|
+
invoke: binding.invoke,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -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', { body: { name: 'x' } });` },
|
|
36
|
+
locked: {
|
|
37
|
+
key: 'items.delete',
|
|
38
|
+
code: `return await {{namespace}}.call('items.delete', { path: { itemId: '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,39 @@
|
|
|
1
|
+
import { startFakeHttp } from '@synoikia/core/testing';
|
|
2
|
+
import type { FakeHttp } from '@synoikia/core/testing';
|
|
3
|
+
import { authorized } from './fake-auth.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A fake {{name}}: just enough of its API to exercise the plugin, including a response that holds a
|
|
7
|
+
* secret, so the tests prove it never reaches the sandbox. Extend it as the plugin grows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const UPSTREAM_SECRET = 'fake-upstream-secret-123';
|
|
11
|
+
|
|
12
|
+
export interface Fake{{Pascal}} extends FakeHttp {
|
|
13
|
+
items: Map<number, { id: number; name: string }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function startFake{{Pascal}}(): Promise<Fake{{Pascal}}> {
|
|
17
|
+
const items = new Map([[1, { id: 1, name: 'First item' }]]);
|
|
18
|
+
let next = 2;
|
|
19
|
+
const fake = await startFakeHttp({
|
|
20
|
+
guard: (req) => (authorized(req.headers) ? undefined : { status: 401, body: { message: 'Unauthorized' } }),
|
|
21
|
+
routes: {
|
|
22
|
+
'GET /api/status': () => ({ body: { version: '1.2.3', name: 'Home {{name}}' } }),
|
|
23
|
+
'GET /api/settings': () => ({ body: { title: 'Home', apiKey: UPSTREAM_SECRET } }),
|
|
24
|
+
'GET /api/items': () => ({ body: [...items.values()] }),
|
|
25
|
+
'POST /api/items': (req) => {
|
|
26
|
+
const item = { id: next++, name: String((req.body as { name?: unknown })?.name ?? 'Unnamed') };
|
|
27
|
+
items.set(item.id, item);
|
|
28
|
+
return { status: 201, body: item };
|
|
29
|
+
},
|
|
30
|
+
'GET /api/items/{itemId}': (req) => {
|
|
31
|
+
const item = items.get(Number(req.params.itemId));
|
|
32
|
+
return item ? { body: item } : { status: 404, body: { message: 'No such item' } };
|
|
33
|
+
},
|
|
34
|
+
'DELETE /api/items/{itemId}': (req) =>
|
|
35
|
+
items.delete(Number(req.params.itemId)) ? { status: 204 } : { status: 404, body: { message: 'No such item' } },
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return { ...fake, items };
|
|
39
|
+
}
|
|
@@ -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', { path: { itemId: '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: { path: { itemId: '1' } }, targets: [] })).toEqual({
|
|
48
|
+
text: '{{name}} items.delete: DELETE /items/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,45 @@
|
|
|
1
|
+
# yaml-language-server: $schema=./node_modules/@synoikia/plugin-sdk/plugin-settings.schema.json
|
|
2
|
+
#
|
|
3
|
+
# What {{name}} tells Synoikia about its JSON-RPC methods. Each method is declared here with its own
|
|
4
|
+
# classification; rules lock and describe them on top. If the upstream can list its methods, build the
|
|
5
|
+
# catalog from that instead and keep only the rules. Reference: docs/plugin-authoring.md.
|
|
6
|
+
|
|
7
|
+
defaults:
|
|
8
|
+
confirm: instance
|
|
9
|
+
timeouts: { request: 15000, lookup: 10000 }
|
|
10
|
+
|
|
11
|
+
# Lookups call a read method with these params.
|
|
12
|
+
instanceName: { op: system.info, field: name, fallback: '{{name}}' }
|
|
13
|
+
|
|
14
|
+
operations:
|
|
15
|
+
- { key: system.info, kind: method, group: system, classification: read, summary: Name and version. }
|
|
16
|
+
- { key: items.list, kind: method, group: items, classification: read, summary: List items. }
|
|
17
|
+
- key: items.get
|
|
18
|
+
kind: method
|
|
19
|
+
group: items
|
|
20
|
+
classification: read
|
|
21
|
+
summary: One item by id.
|
|
22
|
+
paramsSchema: { type: object, properties: { id: { type: number } }, required: [id] }
|
|
23
|
+
- key: items.create
|
|
24
|
+
kind: method
|
|
25
|
+
group: items
|
|
26
|
+
classification: write
|
|
27
|
+
summary: Create an item.
|
|
28
|
+
paramsSchema: { type: object, properties: { name: { type: string } }, required: [name] }
|
|
29
|
+
- key: items.delete
|
|
30
|
+
kind: method
|
|
31
|
+
group: items
|
|
32
|
+
classification: write
|
|
33
|
+
summary: Delete an item by id.
|
|
34
|
+
paramsSchema: { type: object, properties: { id: { type: number } }, required: [id] }
|
|
35
|
+
- { key: settings.get, kind: method, group: settings, classification: read, summary: 'Settings, including an integration key.' }
|
|
36
|
+
|
|
37
|
+
rules:
|
|
38
|
+
- match: items.delete
|
|
39
|
+
locked: true
|
|
40
|
+
confirm:
|
|
41
|
+
lookup: { op: items.get, args: [{ id: { $param: /id } }], field: name }
|
|
42
|
+
|
|
43
|
+
plugin:
|
|
44
|
+
# WebSocket path under the base URL.
|
|
45
|
+
path: /rpc
|