@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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/dist/build.d.ts +16 -0
  4. package/dist/build.js +32 -0
  5. package/dist/build.js.map +1 -0
  6. package/dist/check.d.ts +7 -0
  7. package/dist/check.js +49 -0
  8. package/dist/check.js.map +1 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +104 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/create.d.ts +2 -0
  13. package/dist/create.js +56 -0
  14. package/dist/create.js.map +1 -0
  15. package/dist/files.d.ts +12 -0
  16. package/dist/files.js +42 -0
  17. package/dist/files.js.map +1 -0
  18. package/dist/index.d.ts +10 -0
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/prompts.d.ts +46 -0
  22. package/dist/prompts.js +112 -0
  23. package/dist/prompts.js.map +1 -0
  24. package/dist/repo-root.d.ts +10 -0
  25. package/dist/repo-root.js +47 -0
  26. package/dist/repo-root.js.map +1 -0
  27. package/dist/repo.d.ts +68 -0
  28. package/dist/repo.js +222 -0
  29. package/dist/repo.js.map +1 -0
  30. package/dist/scaffold.d.ts +68 -0
  31. package/dist/scaffold.js +296 -0
  32. package/dist/scaffold.js.map +1 -0
  33. package/dist/vitest.d.ts +12 -0
  34. package/dist/vitest.js +17 -0
  35. package/dist/vitest.js.map +1 -0
  36. package/package.json +56 -0
  37. package/templates/plugin/blank/plugin.yaml +22 -0
  38. package/templates/plugin/blank/src/plugin.ts +74 -0
  39. package/templates/plugin/blank/test/plugin.test.ts +33 -0
  40. package/templates/plugin/common/README.md +23 -0
  41. package/templates/plugin/common/RUNBOOK.md +10 -0
  42. package/templates/plugin/common/src/index.ts +4 -0
  43. package/templates/plugin/common/tsconfig.build.json +9 -0
  44. package/templates/plugin/common/tsconfig.json +7 -0
  45. package/templates/plugin/common/vitest.config.ts +1 -0
  46. package/templates/plugin/openapi-rest/plugin.yaml +49 -0
  47. package/templates/plugin/openapi-rest/src/plugin.ts +89 -0
  48. package/templates/plugin/openapi-rest/test/e2e.test.ts +49 -0
  49. package/templates/plugin/openapi-rest/test/fake-upstream.ts +63 -0
  50. package/templates/plugin/openapi-rest/test/plugin.test.ts +66 -0
  51. package/templates/plugin/static-rest/plugin.yaml +61 -0
  52. package/templates/plugin/static-rest/src/plugin.ts +70 -0
  53. package/templates/plugin/static-rest/test/e2e.test.ts +46 -0
  54. package/templates/plugin/static-rest/test/fake-upstream.ts +39 -0
  55. package/templates/plugin/static-rest/test/plugin.test.ts +67 -0
  56. package/templates/plugin/websocket-rpc/plugin.yaml +45 -0
  57. package/templates/plugin/websocket-rpc/src/client.ts +96 -0
  58. package/templates/plugin/websocket-rpc/src/plugin.ts +98 -0
  59. package/templates/plugin/websocket-rpc/test/e2e.test.ts +46 -0
  60. package/templates/plugin/websocket-rpc/test/fake-upstream.ts +63 -0
  61. package/templates/plugin/websocket-rpc/test/plugin.test.ts +67 -0
  62. package/templates/repo/base/README.md +27 -0
  63. package/templates/repo/base/_github/workflows/ci.yml +27 -0
  64. package/templates/repo/base/_gitignore +6 -0
  65. package/templates/repo/base/_nvmrc +1 -0
  66. package/templates/repo/base/_prettierignore +3 -0
  67. package/templates/repo/base/_prettierrc +5 -0
  68. package/templates/repo/base/eslint.config.js +17 -0
  69. package/templates/repo/base/package.json +34 -0
  70. package/templates/repo/base/plugin-files.d.ts +10 -0
  71. package/templates/repo/base/pnpm-workspace.yaml +11 -0
  72. package/templates/repo/base/tsconfig.base.json +21 -0
  73. package/templates/repo/base/vitest.shared.ts +9 -0
  74. package/templates/repo/claude/CLAUDE.md +38 -0
  75. package/templates/repo/claude/_claude/agents/security-reviewer.md +33 -0
  76. package/templates/repo/claude/_claude/hooks/format.mjs +20 -0
  77. package/templates/repo/claude/_claude/hooks/guard-paths.mjs +43 -0
  78. package/templates/repo/claude/_claude/hooks/session-start.sh +13 -0
  79. package/templates/repo/claude/_claude/settings.json +21 -0
  80. package/templates/repo/claude/_claude/skills/add-plugin/SKILL.md +39 -0
  81. package/templates/repo/claude/_claude/skills/release-plugin/SKILL.md +18 -0
  82. package/templates/repo/release/RELEASING.md +27 -0
  83. 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