@openvcs/sdk 0.2.3 → 0.2.5

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 (54) hide show
  1. package/README.md +39 -3
  2. package/lib/build.d.ts +8 -0
  3. package/lib/build.js +81 -2
  4. package/lib/dist.js +1 -0
  5. package/lib/init.d.ts +2 -0
  6. package/lib/init.js +7 -4
  7. package/lib/runtime/contracts.d.ts +45 -0
  8. package/lib/runtime/contracts.js +4 -0
  9. package/lib/runtime/dispatcher.d.ts +16 -0
  10. package/lib/runtime/dispatcher.js +133 -0
  11. package/lib/runtime/errors.d.ts +5 -0
  12. package/lib/runtime/errors.js +26 -0
  13. package/lib/runtime/factory.d.ts +3 -0
  14. package/lib/runtime/factory.js +153 -0
  15. package/lib/runtime/host.d.ts +10 -0
  16. package/lib/runtime/host.js +48 -0
  17. package/lib/runtime/index.d.ts +10 -0
  18. package/lib/runtime/index.js +23 -0
  19. package/lib/runtime/registration.d.ts +39 -0
  20. package/lib/runtime/registration.js +37 -0
  21. package/lib/runtime/transport.d.ts +14 -0
  22. package/lib/runtime/transport.js +72 -0
  23. package/lib/types/host.d.ts +57 -0
  24. package/lib/types/host.js +4 -0
  25. package/lib/types/index.d.ts +4 -0
  26. package/lib/types/index.js +22 -0
  27. package/lib/types/plugin.d.ts +56 -0
  28. package/lib/types/plugin.js +4 -0
  29. package/lib/types/protocol.d.ts +77 -0
  30. package/lib/types/protocol.js +13 -0
  31. package/lib/types/vcs.d.ts +459 -0
  32. package/lib/types/vcs.js +4 -0
  33. package/package.json +14 -1
  34. package/src/lib/build.ts +104 -2
  35. package/src/lib/dist.ts +2 -0
  36. package/src/lib/init.ts +7 -4
  37. package/src/lib/runtime/contracts.ts +52 -0
  38. package/src/lib/runtime/dispatcher.ts +185 -0
  39. package/src/lib/runtime/errors.ts +27 -0
  40. package/src/lib/runtime/factory.ts +182 -0
  41. package/src/lib/runtime/host.ts +72 -0
  42. package/src/lib/runtime/index.ts +36 -0
  43. package/src/lib/runtime/registration.ts +93 -0
  44. package/src/lib/runtime/transport.ts +93 -0
  45. package/src/lib/types/host.ts +71 -0
  46. package/src/lib/types/index.ts +7 -0
  47. package/src/lib/types/plugin.ts +110 -0
  48. package/src/lib/types/protocol.ts +97 -0
  49. package/src/lib/types/vcs.ts +579 -0
  50. package/test/build.test.js +147 -6
  51. package/test/cli.test.js +5 -3
  52. package/test/dist.test.js +27 -18
  53. package/test/init.test.js +29 -0
  54. package/test/runtime.test.js +235 -0
package/README.md CHANGED
@@ -7,7 +7,9 @@
7
7
  OpenVCS SDK for npm-based plugin development.
8
8
 
9
9
  Install this package in plugin projects, scaffold a starter plugin, and package
10
- plugins into `.ovcsp` bundles.
10
+ plugins into `.ovcsp` bundles. The SDK also exports a Node-only JSON-RPC runtime
11
+ layer and shared protocol/types so plugins do not have to hand-roll stdio
12
+ framing or method dispatch.
11
13
 
12
14
  ## Install
13
15
 
@@ -61,6 +63,35 @@ The generated module template includes TypeScript and Node typings (`@types/node
61
63
  Plugin IDs entered during scaffold must not be `.`/`..` and must not contain path
62
64
  separators (`/` or `\\`).
63
65
 
66
+ Generated module plugins now export a declarative `PluginDefinition` plus an
67
+ `OnPluginStart()` hook that the SDK-owned bootstrap calls for them:
68
+
69
+ ```ts
70
+ import type { PluginModuleDefinition } from '@openvcs/sdk/runtime';
71
+
72
+ export const PluginDefinition: PluginModuleDefinition = {
73
+ plugin: {
74
+ async 'plugin.init'(_params, context) {
75
+ context.host.info('OpenVCS plugin started');
76
+ return null;
77
+ },
78
+ },
79
+ };
80
+
81
+ export function OnPluginStart(): void {}
82
+ ```
83
+
84
+ Runtime and protocol imports are exposed as npm subpaths:
85
+
86
+ ```ts
87
+ import { pluginError } from '@openvcs/sdk/runtime';
88
+ import type { PluginDelegates, VcsDelegates } from '@openvcs/sdk/types';
89
+ ```
90
+
91
+ The runtime handles stdio framing, JSON-RPC request dispatch, host notifications,
92
+ default `plugin.*` handlers, exact-method delegate registration for `vcs.*`, and
93
+ the generated `bin/<module.exec>` bootstrap created by `openvcs build`.
94
+
64
95
  Interactive theme plugin scaffold:
65
96
 
66
97
  ```bash
@@ -75,8 +106,10 @@ In a generated code plugin folder:
75
106
  npm run build
76
107
  ```
77
108
 
78
- This runs `openvcs build`, which executes `scripts["build:plugin"]` and verifies
79
- that `bin/<module.exec>` now exists.
109
+ This runs `openvcs build`, which executes `scripts["build:plugin"]`, expects your
110
+ compiled plugin author module at `bin/plugin.js`, and then generates the SDK-owned
111
+ `bin/<module.exec>` bootstrap that imports `./plugin.js`, applies `PluginDefinition`,
112
+ invokes `OnPluginStart()`, and starts the runtime.
80
113
 
81
114
  Theme-only plugins can also run `npm run build`; the command exits successfully
82
115
  without producing `bin/` output.
@@ -106,6 +139,9 @@ Generated code plugin scripts use this split by default:
106
139
  }
107
140
  ```
108
141
 
142
+ For code plugins, reserve `bin/plugin.js` for the compiled author module and point
143
+ `module.exec` at a different bootstrap filename such as `openvcs-plugin.js`.
144
+
109
145
  `.ovcsp` is a gzip-compressed tar archive (`tar.gz`) that contains a top-level
110
146
  `<plugin-id>/` directory with `openvcs.plugin.json` and plugin runtime assets.
111
147
 
package/lib/build.d.ts CHANGED
@@ -20,6 +20,14 @@ export declare function parseBuildArgs(args: string[]): BuildArgs;
20
20
  export declare function readManifest(pluginDir: string): ManifestInfo;
21
21
  /** Verifies that a declared module entry resolves to a real file under `bin/`. */
22
22
  export declare function validateDeclaredModuleExec(pluginDir: string, moduleExec: string | undefined): void;
23
+ /** Returns the compiled plugin module path imported by the generated bootstrap. */
24
+ export declare function authoredPluginModulePath(pluginDir: string): string;
25
+ /** Ensures the plugin's authored module and generated bootstrap paths are compatible. */
26
+ export declare function validateGeneratedBootstrapTargets(pluginDir: string, moduleExec: string | undefined): void;
27
+ /** Renders the generated Node bootstrap that owns runtime startup. */
28
+ export declare function renderGeneratedBootstrap(pluginModuleImportPath: string, isEsm: boolean): string;
29
+ /** Writes the generated SDK-owned module entrypoint under `bin/<module.exec>`. */
30
+ export declare function generateModuleBootstrap(pluginDir: string, moduleExec: string | undefined): void;
23
31
  /** Returns whether the plugin repository has a `package.json`. */
24
32
  export declare function hasPackageJson(pluginDir: string): boolean;
25
33
  /** Runs a command in the given directory with optional verbose logging. */
package/lib/build.js CHANGED
@@ -7,6 +7,10 @@ exports.buildUsage = buildUsage;
7
7
  exports.parseBuildArgs = parseBuildArgs;
8
8
  exports.readManifest = readManifest;
9
9
  exports.validateDeclaredModuleExec = validateDeclaredModuleExec;
10
+ exports.authoredPluginModulePath = authoredPluginModulePath;
11
+ exports.validateGeneratedBootstrapTargets = validateGeneratedBootstrapTargets;
12
+ exports.renderGeneratedBootstrap = renderGeneratedBootstrap;
13
+ exports.generateModuleBootstrap = generateModuleBootstrap;
10
14
  exports.hasPackageJson = hasPackageJson;
11
15
  exports.runCommand = runCommand;
12
16
  exports.buildPluginAssets = buildPluginAssets;
@@ -14,6 +18,7 @@ const fs = require("node:fs");
14
18
  const path = require("node:path");
15
19
  const node_child_process_1 = require("node:child_process");
16
20
  const fs_utils_1 = require("./fs-utils");
21
+ const AUTHORED_PLUGIN_MODULE_BASENAME = "plugin.js";
17
22
  /** Returns the npm executable name for the current platform. */
18
23
  function npmExecutable() {
19
24
  return process.platform === "win32" ? "npm.cmd" : "npm";
@@ -109,6 +114,13 @@ function validateDeclaredModuleExec(pluginDir, moduleExec) {
109
114
  if (!moduleExec) {
110
115
  return;
111
116
  }
117
+ const targetPath = resolveDeclaredModuleExecPath(pluginDir, moduleExec);
118
+ if (!fs.existsSync(targetPath) || !fs.lstatSync(targetPath).isFile()) {
119
+ throw new Error(`module entrypoint not found at ${targetPath}`);
120
+ }
121
+ }
122
+ /** Resolves `module.exec` to an absolute path under `bin/`, rejecting invalid targets. */
123
+ function resolveDeclaredModuleExecPath(pluginDir, moduleExec) {
112
124
  const normalizedExec = moduleExec.trim();
113
125
  const lowered = normalizedExec.toLowerCase();
114
126
  if (!lowered.endsWith(".js") && !lowered.endsWith(".mjs") && !lowered.endsWith(".cjs")) {
@@ -122,9 +134,75 @@ function validateDeclaredModuleExec(pluginDir, moduleExec) {
122
134
  if (!(0, fs_utils_1.isPathInside)(binDir, targetPath) || targetPath === binDir) {
123
135
  throw new Error(`manifest module.exec must point to a file under bin/: ${moduleExec}`);
124
136
  }
125
- if (!fs.existsSync(targetPath) || !fs.lstatSync(targetPath).isFile()) {
126
- throw new Error(`module entrypoint not found at ${targetPath}`);
137
+ return targetPath;
138
+ }
139
+ /** Returns the compiled plugin module path imported by the generated bootstrap. */
140
+ function authoredPluginModulePath(pluginDir) {
141
+ return path.resolve(pluginDir, "bin", AUTHORED_PLUGIN_MODULE_BASENAME);
142
+ }
143
+ /** Ensures the plugin's authored module and generated bootstrap paths are compatible. */
144
+ function validateGeneratedBootstrapTargets(pluginDir, moduleExec) {
145
+ if (!moduleExec) {
146
+ return;
147
+ }
148
+ resolveDeclaredModuleExecPath(pluginDir, moduleExec);
149
+ const normalizedExec = moduleExec.trim().toLowerCase();
150
+ if (normalizedExec === AUTHORED_PLUGIN_MODULE_BASENAME.toLowerCase()) {
151
+ throw new Error(`manifest module.exec must not be ${AUTHORED_PLUGIN_MODULE_BASENAME}; SDK reserves bin/${AUTHORED_PLUGIN_MODULE_BASENAME} for the compiled OnPluginStart module`);
152
+ }
153
+ const authoredModulePath = authoredPluginModulePath(pluginDir);
154
+ if (!fs.existsSync(authoredModulePath) || !fs.lstatSync(authoredModulePath).isFile()) {
155
+ throw new Error(`compiled plugin module not found at ${authoredModulePath}; build:plugin must emit bin/${AUTHORED_PLUGIN_MODULE_BASENAME}`);
156
+ }
157
+ }
158
+ /** Returns a normalized import specifier from one bin file to another. */
159
+ function relativeBinImport(fromExecPath, toModulePath) {
160
+ const relativePath = path.relative(path.dirname(fromExecPath), toModulePath).replace(/\\/g, "/");
161
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
162
+ }
163
+ /** Validates that a module import path contains only safe characters for code generation. */
164
+ function validateModuleImportPath(importPath) {
165
+ if (!/^[./a-zA-Z0-9_-]+$/.test(importPath)) {
166
+ throw new Error(`unsafe module import path: ${importPath}; path must contain only alphanumeric characters, dots, slashes, hyphens, and underscores`);
167
+ }
168
+ }
169
+ /** Renders the generated Node bootstrap that owns runtime startup. */
170
+ function renderGeneratedBootstrap(pluginModuleImportPath, isEsm) {
171
+ validateModuleImportPath(pluginModuleImportPath);
172
+ if (isEsm) {
173
+ return `#!/usr/bin/env node\n// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\nimport { bootstrapPluginModule } from '@openvcs/sdk/runtime';\n\nawait bootstrapPluginModule({\n importPluginModule: async () => import('${pluginModuleImportPath}'),\n modulePath: '${pluginModuleImportPath}',\n});\n`;
174
+ }
175
+ return `#!/usr/bin/env node\n// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\n(async () => {\n const { bootstrapPluginModule } = require('@openvcs/sdk/runtime');\n await bootstrapPluginModule({\n importPluginModule: async () => require('${pluginModuleImportPath}'),\n modulePath: '${pluginModuleImportPath}',\n });\n})();\n`;
176
+ }
177
+ /** Writes the generated SDK-owned module entrypoint under `bin/<module.exec>`. */
178
+ function generateModuleBootstrap(pluginDir, moduleExec) {
179
+ if (!moduleExec) {
180
+ console.debug(`generateModuleBootstrap: no module.exec defined, skipping bootstrap generation for ${pluginDir}`);
181
+ return;
182
+ }
183
+ validateGeneratedBootstrapTargets(pluginDir, moduleExec);
184
+ const execPath = resolveDeclaredModuleExecPath(pluginDir, moduleExec);
185
+ const pluginModulePath = authoredPluginModulePath(pluginDir);
186
+ const pluginModuleImportPath = relativeBinImport(execPath, pluginModulePath);
187
+ const isEsm = detectEsmMode(pluginDir, moduleExec);
188
+ fs.mkdirSync(path.dirname(execPath), { recursive: true });
189
+ fs.writeFileSync(execPath, renderGeneratedBootstrap(pluginModuleImportPath, isEsm), "utf8");
190
+ }
191
+ /** Detects whether the plugin runs in ESM mode based on package.json or file extension. */
192
+ function detectEsmMode(pluginDir, moduleExec) {
193
+ const packageJsonPath = path.join(pluginDir, "package.json");
194
+ if (fs.existsSync(packageJsonPath) && fs.lstatSync(packageJsonPath).isFile()) {
195
+ try {
196
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
197
+ if (packageJson.type === "module") {
198
+ return true;
199
+ }
200
+ }
201
+ catch {
202
+ // Ignore JSON parse errors
203
+ }
127
204
  }
205
+ return moduleExec.trim().endsWith(".mjs");
128
206
  }
129
207
  /** Returns whether the plugin repository has a `package.json`. */
130
208
  function hasPackageJson(pluginDir) {
@@ -183,6 +261,7 @@ function buildPluginAssets(parsedArgs) {
183
261
  throw new Error(`code plugins must define scripts[\"build:plugin\"] in ${path.join(parsedArgs.pluginDir, "package.json")}`);
184
262
  }
185
263
  runCommand(npmExecutable(), ["run", "build:plugin"], parsedArgs.pluginDir, parsedArgs.verbose);
264
+ generateModuleBootstrap(parsedArgs.pluginDir, manifest.moduleExec);
186
265
  validateDeclaredModuleExec(parsedArgs.pluginDir, manifest.moduleExec);
187
266
  return manifest;
188
267
  }
package/lib/dist.js CHANGED
@@ -195,6 +195,7 @@ async function bundlePlugin(parsedArgs) {
195
195
  if (!moduleExec && !hasThemes && !entry) {
196
196
  throw new Error("manifest has no module.exec, entry, or themes/");
197
197
  }
198
+ (0, build_1.validateGeneratedBootstrapTargets)(pluginDir, moduleExec);
198
199
  (0, build_1.validateDeclaredModuleExec)(pluginDir, moduleExec);
199
200
  (0, fs_utils_1.ensureDirectory)(outDir);
200
201
  const stagingRoot = uniqueStagingDir(outDir);
package/lib/init.d.ts CHANGED
@@ -25,6 +25,7 @@ declare function defaultPluginIdFromDir(targetDir: string): string;
25
25
  declare function validatePluginId(pluginId: string): string | undefined;
26
26
  declare function createReadlinePromptDriver(output?: NodeJS.WritableStream): PromptDriver;
27
27
  declare function collectAnswers({ forceTheme, targetHint }: CollectAnswersOptions, promptDriver?: PromptDriver, output?: NodeJS.WritableStream): Promise<InitAnswers>;
28
+ declare function writeModuleTemplate(answers: InitAnswers): void;
28
29
  export declare function runInitCommand(args: string[]): Promise<string>;
29
30
  export declare function isUsageError(error: unknown): error is InitCommandError;
30
31
  export declare const __private: {
@@ -33,5 +34,6 @@ export declare const __private: {
33
34
  defaultPluginIdFromDir: typeof defaultPluginIdFromDir;
34
35
  sanitizeIdToken: typeof sanitizeIdToken;
35
36
  validatePluginId: typeof validatePluginId;
37
+ writeModuleTemplate: typeof writeModuleTemplate;
36
38
  };
37
39
  export {};
package/lib/init.js CHANGED
@@ -180,7 +180,7 @@ function writeCommonFiles(answers) {
180
180
  name: answers.pluginName,
181
181
  version: answers.pluginVersion,
182
182
  default_enabled: answers.defaultEnabled,
183
- ...(answers.kind === "module" ? { module: { exec: "plugin.js" } } : {}),
183
+ ...(answers.kind === "module" ? { module: { exec: "openvcs-plugin.js" } } : {}),
184
184
  });
185
185
  writeText(path.join(answers.targetDir, ".gitignore"), "node_modules/\ndist/\n");
186
186
  }
@@ -197,8 +197,10 @@ function writeModuleTemplate(answers) {
197
197
  dist: "openvcs dist --plugin-dir . --out dist",
198
198
  test: "openvcs dist --plugin-dir . --out dist --no-build --no-npm-deps",
199
199
  },
200
- devDependencies: {
200
+ dependencies: {
201
201
  "@openvcs/sdk": `^${packageJson.version}`,
202
+ },
203
+ devDependencies: {
202
204
  "@types/node": "^22.0.0",
203
205
  typescript: "^5.8.2",
204
206
  },
@@ -216,7 +218,7 @@ function writeModuleTemplate(answers) {
216
218
  },
217
219
  include: ["src/**/*.ts"],
218
220
  });
219
- writeText(path.join(answers.targetDir, "src", "plugin.ts"), "const message = \"OpenVCS plugin started\";\nprocess.stderr.write(`${message}\\n`);\n");
221
+ writeText(path.join(answers.targetDir, "src", "plugin.ts"), "// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\nimport type { PluginModuleDefinition } from '@openvcs/sdk/runtime';\n\nexport const PluginDefinition: PluginModuleDefinition = {\n plugin: {\n async 'plugin.init'(_params, context) {\n context.host.info('OpenVCS plugin started');\n return null;\n },\n },\n};\n\n/** Runs plugin startup work before the generated runtime begins processing requests. */\nexport function OnPluginStart(): void {}\n");
220
222
  }
221
223
  function writeThemeTemplate(answers) {
222
224
  writeCommonFiles(answers);
@@ -229,7 +231,7 @@ function writeThemeTemplate(answers) {
229
231
  dist: "openvcs dist --plugin-dir . --out dist",
230
232
  test: "openvcs dist --plugin-dir . --out dist --no-build --no-npm-deps",
231
233
  },
232
- devDependencies: {
234
+ dependencies: {
233
235
  "@openvcs/sdk": `^${packageJson.version}`,
234
236
  },
235
237
  });
@@ -306,4 +308,5 @@ exports.__private = {
306
308
  defaultPluginIdFromDir,
307
309
  sanitizeIdToken,
308
310
  validatePluginId,
311
+ writeModuleTemplate,
309
312
  };
@@ -0,0 +1,45 @@
1
+ import type { PluginHost, PluginImplements, PluginDelegates, JsonRpcId, JsonRpcRequest, VcsDelegates } from '../types';
2
+ /** Describes the transport endpoints used by the plugin runtime loop. */
3
+ export interface PluginRuntimeTransport {
4
+ /** Stores the readable stdin-like stream receiving framed messages. */
5
+ stdin: NodeJS.ReadStream;
6
+ /** Stores the writable stdout-like stream sending framed messages. */
7
+ stdout: NodeJS.WritableStream;
8
+ }
9
+ /** Describes the context object passed to every SDK delegate handler. */
10
+ export interface PluginRuntimeContext {
11
+ /** Stores the active host notification helper. */
12
+ host: PluginHost;
13
+ /** Stores the request id currently being processed. */
14
+ requestId: JsonRpcId;
15
+ /** Stores the current host method name. */
16
+ method: string;
17
+ }
18
+ /** Describes the options accepted by `createPluginRuntime`. */
19
+ export interface CreatePluginRuntimeOptions {
20
+ /** Stores optional plugin lifecycle and settings delegates. */
21
+ plugin?: PluginDelegates<PluginRuntimeContext>;
22
+ /** Stores optional VCS method delegates. */
23
+ vcs?: VcsDelegates<PluginRuntimeContext>;
24
+ /** Stores optional capability overrides for `plugin.initialize`. */
25
+ implements?: Partial<PluginImplements>;
26
+ /** Stores the `host.log` target emitted by the runtime. */
27
+ logTarget?: string;
28
+ /** Stores the timeout in milliseconds for request handlers. */
29
+ timeout?: number;
30
+ /** Called when runtime starts and begins processing requests. */
31
+ onStart?: () => void | Promise<void>;
32
+ /** Called during stop() after pending operations complete. Called with error if shutdown due to processing error. */
33
+ onShutdown?: (error?: Error) => void | Promise<void>;
34
+ }
35
+ /** Describes one created SDK plugin runtime instance. */
36
+ export interface PluginRuntime {
37
+ /** Starts listening on stdio for framed JSON-RPC requests. */
38
+ start(transport?: PluginRuntimeTransport): void;
39
+ /** Stops the runtime and cleans up pending operations. */
40
+ stop(): void;
41
+ /** Consumes one raw stdio chunk and dispatches complete requests. */
42
+ consumeChunk(chunk: Buffer | string): void;
43
+ /** Dispatches one already-decoded JSON-RPC request. */
44
+ dispatchRequest(request: JsonRpcRequest): Promise<void>;
45
+ }
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ // Copyright © 2025-2026 OpenVCS Contributors
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,16 @@
1
+ import type { JsonRpcId, PluginDelegates, RequestParams } from '../types';
2
+ import type { PluginHost } from '../types';
3
+ import type { CreatePluginRuntimeOptions } from './contracts';
4
+ /** Describes the response emitter used by the dispatcher. */
5
+ export interface DispatcherResponseWriter {
6
+ /** Emits a JSON-RPC success response. */
7
+ sendResult<TResult>(id: JsonRpcId, result: TResult): void;
8
+ /** Emits a JSON-RPC error response. */
9
+ sendError(id: JsonRpcId, code: number, message: string, data?: unknown): void;
10
+ }
11
+ /** Describes the request handler built by `createRuntimeDispatcher`. */
12
+ export type RuntimeRequestDispatcher = (id: JsonRpcId, method: string, params: RequestParams) => Promise<void>;
13
+ /** Creates the plugin default handlers used when a delegate is omitted. */
14
+ export declare function createDefaultPluginDelegates<TContext>(): Required<Omit<PluginDelegates<TContext>, 'plugin.initialize'>>;
15
+ /** Creates the JSON-RPC dispatcher used by the SDK plugin runtime. */
16
+ export declare function createRuntimeDispatcher(options: CreatePluginRuntimeOptions, host: PluginHost, writer: DispatcherResponseWriter): RuntimeRequestDispatcher;
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ // Copyright © 2025-2026 OpenVCS Contributors
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.createDefaultPluginDelegates = createDefaultPluginDelegates;
6
+ exports.createRuntimeDispatcher = createRuntimeDispatcher;
7
+ const types_1 = require("../types");
8
+ const errors_1 = require("./errors");
9
+ /** Creates the plugin default handlers used when a delegate is omitted. */
10
+ function createDefaultPluginDelegates() {
11
+ return {
12
+ async 'plugin.init'() {
13
+ return null;
14
+ },
15
+ async 'plugin.deinit'() {
16
+ return null;
17
+ },
18
+ async 'plugin.get_menus'() {
19
+ return [];
20
+ },
21
+ async 'plugin.handle_action'() {
22
+ return null;
23
+ },
24
+ async 'plugin.settings.defaults'() {
25
+ return [];
26
+ },
27
+ async 'plugin.settings.on_load'(params) {
28
+ return Array.isArray(params.values) ? params.values : [];
29
+ },
30
+ async 'plugin.settings.on_apply'() {
31
+ return null;
32
+ },
33
+ async 'plugin.settings.on_save'(params) {
34
+ return Array.isArray(params.values) ? params.values : [];
35
+ },
36
+ async 'plugin.settings.on_reset'() {
37
+ return null;
38
+ },
39
+ };
40
+ }
41
+ /** Creates the JSON-RPC dispatcher used by the SDK plugin runtime. */
42
+ function createRuntimeDispatcher(options, host, writer) {
43
+ const defaultPluginDelegates = createDefaultPluginDelegates();
44
+ const pluginDelegates = options.plugin ?? {};
45
+ const vcsDelegates = options.vcs ?? {};
46
+ const runtimeImplements = buildRuntimeImplements(options.implements, options.vcs);
47
+ const timeout = options.timeout;
48
+ const typedPluginDelegates = pluginDelegates;
49
+ const typedDefaultPluginDelegates = defaultPluginDelegates;
50
+ const typedVcsDelegates = vcsDelegates;
51
+ return async (id, method, params) => {
52
+ try {
53
+ if (method === 'plugin.initialize') {
54
+ const expectedVersion = params.expected_protocol_version;
55
+ if (typeof expectedVersion === 'number' && expectedVersion !== types_1.PROTOCOL_VERSION) {
56
+ writer.sendError(id, types_1.PROTOCOL_VERSION_MISMATCH_CODE, 'protocol version mismatch', {
57
+ code: 'protocol-version-mismatch',
58
+ message: `host expects protocol ${expectedVersion}, plugin supports ${types_1.PROTOCOL_VERSION}`,
59
+ });
60
+ return;
61
+ }
62
+ const override = pluginDelegates['plugin.initialize']
63
+ ? await pluginDelegates['plugin.initialize'](params, {
64
+ host,
65
+ requestId: id,
66
+ method,
67
+ })
68
+ : {};
69
+ writer.sendResult(id, {
70
+ protocol_version: override.protocol_version ?? types_1.PROTOCOL_VERSION,
71
+ implements: {
72
+ ...runtimeImplements,
73
+ ...override.implements,
74
+ },
75
+ });
76
+ return;
77
+ }
78
+ const handler = typedPluginDelegates[method] ??
79
+ typedDefaultPluginDelegates[method] ??
80
+ typedVcsDelegates[method];
81
+ if (!handler) {
82
+ throw (0, errors_1.pluginError)('rpc-method-not-found', `method '${method}' is not implemented`);
83
+ }
84
+ let result;
85
+ if (timeout && timeout > 0) {
86
+ const controller = new AbortController();
87
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
88
+ try {
89
+ result = await handler(params, {
90
+ host,
91
+ requestId: id,
92
+ method,
93
+ });
94
+ }
95
+ catch (error) {
96
+ clearTimeout(timeoutId);
97
+ if (error.name === 'AbortError') {
98
+ throw (0, errors_1.pluginError)('request-timeout', `method '${method}' timed out after ${timeout}ms`);
99
+ }
100
+ throw error;
101
+ }
102
+ clearTimeout(timeoutId);
103
+ }
104
+ else {
105
+ result = await handler(params, {
106
+ host,
107
+ requestId: id,
108
+ method,
109
+ });
110
+ }
111
+ writer.sendResult(id, result == null ? null : result);
112
+ }
113
+ catch (error) {
114
+ if ((0, errors_1.isPluginFailure)(error)) {
115
+ writer.sendError(id, error.code, error.message, error.data);
116
+ return;
117
+ }
118
+ const messageText = error instanceof Error ? error.message : String(error || 'unknown error');
119
+ host.error(messageText);
120
+ writer.sendError(id, types_1.PLUGIN_INTERNAL_ERROR_CODE, messageText, {
121
+ code: 'plugin-internal-error',
122
+ message: messageText,
123
+ });
124
+ }
125
+ };
126
+ }
127
+ /** Builds the handshake capability flags returned from `plugin.initialize`. */
128
+ function buildRuntimeImplements(overrides, vcsDelegates) {
129
+ return {
130
+ plugin: overrides?.plugin ?? true,
131
+ vcs: overrides?.vcs ?? Boolean(vcsDelegates && Object.keys(vcsDelegates).length > 0),
132
+ };
133
+ }
@@ -0,0 +1,5 @@
1
+ import type { PluginFailure } from '../types';
2
+ /** Builds the host-facing plugin failure payload used for operational errors. */
3
+ export declare function pluginError(code: string, message: string): PluginFailure;
4
+ /** Returns whether the supplied value is a structured plugin failure. */
5
+ export declare function isPluginFailure(value: unknown): value is PluginFailure;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ // Copyright © 2025-2026 OpenVCS Contributors
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.pluginError = pluginError;
6
+ exports.isPluginFailure = isPluginFailure;
7
+ const types_1 = require("../types");
8
+ /** Builds the host-facing plugin failure payload used for operational errors. */
9
+ function pluginError(code, message) {
10
+ return {
11
+ code: types_1.PLUGIN_FAILURE_CODE,
12
+ message,
13
+ data: {
14
+ code,
15
+ message,
16
+ },
17
+ };
18
+ }
19
+ /** Returns whether the supplied value is a structured plugin failure. */
20
+ function isPluginFailure(value) {
21
+ if (value == null || typeof value !== 'object') {
22
+ return false;
23
+ }
24
+ const candidate = value;
25
+ return candidate.code === types_1.PLUGIN_FAILURE_CODE && typeof candidate.message === 'string';
26
+ }
@@ -0,0 +1,3 @@
1
+ import type { CreatePluginRuntimeOptions, PluginRuntime } from './contracts';
2
+ /** Creates a reusable stdio JSON-RPC runtime for OpenVCS Node plugins. */
3
+ export declare function createPluginRuntime(options?: CreatePluginRuntimeOptions): PluginRuntime;
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ // Copyright © 2025-2026 OpenVCS Contributors
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.createPluginRuntime = createPluginRuntime;
6
+ const dispatcher_1 = require("./dispatcher");
7
+ const host_1 = require("./host");
8
+ const transport_1 = require("./transport");
9
+ /** Creates a reusable stdio JSON-RPC runtime for OpenVCS Node plugins. */
10
+ function createPluginRuntime(options = {}) {
11
+ let buffer = Buffer.alloc(0);
12
+ let processing = Promise.resolve();
13
+ let started = false;
14
+ let currentTransport = null;
15
+ let chunkLock = Promise.resolve();
16
+ const runtime = {
17
+ start(transport = defaultTransport()) {
18
+ if (started) {
19
+ return;
20
+ }
21
+ started = true;
22
+ currentTransport = transport;
23
+ transport.stdin.on('data', (chunk) => {
24
+ runtime.consumeChunk(chunk);
25
+ });
26
+ transport.stdin.on('error', () => {
27
+ process.exit(1);
28
+ });
29
+ options.onStart?.();
30
+ },
31
+ stop() {
32
+ if (!started) {
33
+ return;
34
+ }
35
+ started = false;
36
+ processing = processing
37
+ .catch(async (error) => {
38
+ const errorMessage = error instanceof Error ? error.message : String(error);
39
+ console.error(`[runtime] shutdown error: ${errorMessage}`);
40
+ const err = error instanceof Error ? error : new Error(errorMessage);
41
+ await options.onShutdown?.(err);
42
+ })
43
+ .then(async () => {
44
+ await options.onShutdown?.();
45
+ });
46
+ currentTransport = null;
47
+ },
48
+ consumeChunk(chunk) {
49
+ if (!started) {
50
+ return;
51
+ }
52
+ const lock = chunkLock.then(() => {
53
+ buffer = Buffer.concat([buffer, normalizeChunk(chunk)]);
54
+ const parsed = (0, transport_1.parseFramedMessages)(buffer);
55
+ buffer = parsed.remainder;
56
+ for (const request of parsed.messages) {
57
+ processing = processing
58
+ .then(async () => {
59
+ await runtime.dispatchRequest(request);
60
+ })
61
+ .catch((error) => {
62
+ const message = error instanceof Error ? error.message : String(error || 'unknown plugin processing error');
63
+ const host = createRuntimeHost(currentTransport, options.logTarget);
64
+ host.error(message);
65
+ });
66
+ }
67
+ });
68
+ chunkLock = lock;
69
+ },
70
+ async dispatchRequest(request) {
71
+ const id = request.id;
72
+ const host = createRuntimeHost(currentTransport, options.logTarget);
73
+ const method = asTrimmedString(request.method);
74
+ const validationErrors = [];
75
+ if (!method)
76
+ validationErrors.push('missing method');
77
+ if (typeof id !== 'number' && typeof id !== 'string')
78
+ validationErrors.push(`invalid id type: ${typeof id}`);
79
+ if (validationErrors.length > 0) {
80
+ host.error(`invalid request: ${validationErrors.join(', ')}`);
81
+ return;
82
+ }
83
+ const dispatcher = (0, dispatcher_1.createRuntimeDispatcher)(options, host, {
84
+ async sendResult(requestId, result) {
85
+ await sendMessage(currentTransport, {
86
+ jsonrpc: '2.0',
87
+ id: requestId,
88
+ result,
89
+ });
90
+ },
91
+ async sendError(requestId, code, message, data) {
92
+ await sendMessage(currentTransport, {
93
+ jsonrpc: '2.0',
94
+ id: requestId,
95
+ error: {
96
+ code,
97
+ message,
98
+ ...(data == null ? {} : { data }),
99
+ },
100
+ });
101
+ },
102
+ });
103
+ const methodName = method;
104
+ const params = asRecord(request.params) ?? {};
105
+ const requestId = id;
106
+ await dispatcher(requestId, methodName, params);
107
+ },
108
+ };
109
+ return runtime;
110
+ }
111
+ /** Returns the default stdio transport used by the runtime. */
112
+ function defaultTransport() {
113
+ return {
114
+ stdin: process.stdin,
115
+ stdout: process.stdout,
116
+ };
117
+ }
118
+ /** Sends one JSON-RPC response or notification through the active transport. */
119
+ async function sendMessage(transport, value) {
120
+ await (0, transport_1.writeFramedMessage)((transport ?? defaultTransport()).stdout, value);
121
+ }
122
+ /** Builds the host helper for the currently active transport. */
123
+ function createRuntimeHost(transport, logTarget) {
124
+ return (0, host_1.createHost)(async (method, params) => {
125
+ await sendMessage(transport, {
126
+ jsonrpc: '2.0',
127
+ method,
128
+ params,
129
+ });
130
+ }, { logTarget });
131
+ }
132
+ /** Type guard that returns true if the value is a valid RequestParams object. */
133
+ function isRequestParams(value) {
134
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
135
+ return false;
136
+ }
137
+ return true;
138
+ }
139
+ /** Coerces unknown request method values into trimmed strings. Returns null for non-strings. */
140
+ function asTrimmedString(value) {
141
+ return typeof value === 'string' ? value.trim() : null;
142
+ }
143
+ /** Coerces unknown params to a Record, returning null for invalid input. */
144
+ function asRecord(value) {
145
+ if (isRequestParams(value)) {
146
+ return value;
147
+ }
148
+ return null;
149
+ }
150
+ /** Normalizes incoming data chunks to UTF-8 buffers. */
151
+ function normalizeChunk(chunk) {
152
+ return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
153
+ }