@openvcs/sdk 0.2.4 → 0.2.6
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/README.md +68 -9
- package/lib/build.d.ts +8 -0
- package/lib/build.js +81 -2
- package/lib/dist.js +1 -0
- package/lib/init.js +2 -2
- package/lib/runtime/factory.d.ts +3 -0
- package/lib/runtime/factory.js +153 -0
- package/lib/runtime/index.d.ts +7 -4
- package/lib/runtime/index.js +13 -154
- package/lib/runtime/registration.d.ts +39 -0
- package/lib/runtime/registration.js +37 -0
- package/lib/runtime/vcs-delegate-base.d.ts +125 -0
- package/lib/runtime/vcs-delegate-base.js +249 -0
- package/lib/runtime/vcs-delegate-metadata.d.ts +120 -0
- package/lib/runtime/vcs-delegate-metadata.js +58 -0
- package/package.json +3 -2
- package/src/lib/build.ts +104 -2
- package/src/lib/dist.ts +2 -0
- package/src/lib/init.ts +2 -2
- package/src/lib/runtime/factory.ts +182 -0
- package/src/lib/runtime/index.ts +14 -177
- package/src/lib/runtime/registration.ts +93 -0
- package/src/lib/runtime/vcs-delegate-base.ts +481 -0
- package/src/lib/runtime/vcs-delegate-metadata.ts +165 -0
- package/test/build.test.js +147 -6
- package/test/cli.test.js +5 -3
- package/test/dist.test.js +27 -18
- package/test/init.test.js +6 -2
- package/test/runtime.test.js +118 -1
- package/test/tsconfig.json +8 -0
- package/test/vcs-delegate-base.test.js +169 -0
- package/test/vcs-delegate-base.types.ts +44 -0
package/README.md
CHANGED
|
@@ -63,32 +63,86 @@ The generated module template includes TypeScript and Node typings (`@types/node
|
|
|
63
63
|
Plugin IDs entered during scaffold must not be `.`/`..` and must not contain path
|
|
64
64
|
separators (`/` or `\\`).
|
|
65
65
|
|
|
66
|
-
Generated module plugins now
|
|
66
|
+
Generated module plugins now export a declarative `PluginDefinition` plus an
|
|
67
|
+
`OnPluginStart()` hook that the SDK-owned bootstrap calls for them:
|
|
67
68
|
|
|
68
69
|
```ts
|
|
69
|
-
import {
|
|
70
|
+
import type { PluginModuleDefinition } from '@openvcs/sdk/runtime';
|
|
70
71
|
|
|
71
|
-
const
|
|
72
|
+
export const PluginDefinition: PluginModuleDefinition = {
|
|
72
73
|
plugin: {
|
|
73
74
|
async 'plugin.init'(_params, context) {
|
|
74
75
|
context.host.info('OpenVCS plugin started');
|
|
75
76
|
return null;
|
|
76
77
|
},
|
|
77
78
|
},
|
|
78
|
-
}
|
|
79
|
+
};
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
export function OnPluginStart(): void {}
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
VCS backends can also derive from `VcsDelegateBase` and attach exact `vcs.*`
|
|
85
|
+
delegates during startup:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import {
|
|
89
|
+
VcsDelegateBase,
|
|
90
|
+
type PluginRuntimeContext,
|
|
91
|
+
type PluginModuleDefinition,
|
|
92
|
+
} from '@openvcs/sdk/runtime';
|
|
93
|
+
import type { RequestParams, VcsCapabilities } from '@openvcs/sdk/types';
|
|
94
|
+
|
|
95
|
+
class ExampleVcsDelegates extends VcsDelegateBase<{ cwd: string }> {
|
|
96
|
+
override getCaps(
|
|
97
|
+
_params: RequestParams,
|
|
98
|
+
_context: PluginRuntimeContext,
|
|
99
|
+
): VcsCapabilities {
|
|
100
|
+
return {
|
|
101
|
+
commits: true,
|
|
102
|
+
branches: true,
|
|
103
|
+
tags: false,
|
|
104
|
+
staging: true,
|
|
105
|
+
push_pull: true,
|
|
106
|
+
fast_forward: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const PluginDefinition: PluginModuleDefinition = {};
|
|
112
|
+
|
|
113
|
+
export function OnPluginStart(): void {
|
|
114
|
+
const vcs = new ExampleVcsDelegates({ cwd: process.cwd() });
|
|
115
|
+
PluginDefinition.vcs = vcs.toDelegates();
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Define ordinary prototype methods such as `getCaps()` and `commitIndex()` on the
|
|
120
|
+
subclass. `toDelegates()` maps those camelCase methods to the exact host method
|
|
121
|
+
names like `vcs.get_caps` and `vcs.commit_index`, and only registers methods
|
|
122
|
+
that differ from the SDK base class. Use `override` with the full params/context
|
|
123
|
+
signature so TypeScript checks your subclass against the SDK contract. Base
|
|
124
|
+
stubs throw through an internal `never`-returning helper, which is why concrete
|
|
125
|
+
plugins should always implement the methods they intend to expose.
|
|
126
|
+
|
|
127
|
+
### Compile-time override safety
|
|
128
|
+
|
|
129
|
+
The SDK enforces correct method signatures at compile time. Subclasses that
|
|
130
|
+
override with the wrong return type or parameter shape produce a TypeScript error.
|
|
131
|
+
The SDK test suite includes `test/vcs-delegate-base.types.ts` which explicitly
|
|
132
|
+
verifies that incompatible overrides (e.g. returning `string` instead of
|
|
133
|
+
`VcsCapabilities`) fail the compiler under `@ts-expect-error`. Runtime tests
|
|
134
|
+
in `test/vcs-delegate-base.test.js` cover behavior — not signatures.
|
|
135
|
+
|
|
83
136
|
Runtime and protocol imports are exposed as npm subpaths:
|
|
84
137
|
|
|
85
138
|
```ts
|
|
86
|
-
import {
|
|
139
|
+
import { VcsDelegateBase, pluginError } from '@openvcs/sdk/runtime';
|
|
87
140
|
import type { PluginDelegates, VcsDelegates } from '@openvcs/sdk/types';
|
|
88
141
|
```
|
|
89
142
|
|
|
90
143
|
The runtime handles stdio framing, JSON-RPC request dispatch, host notifications,
|
|
91
|
-
default `plugin.*` handlers,
|
|
144
|
+
default `plugin.*` handlers, exact-method delegate registration for `vcs.*`, and
|
|
145
|
+
the generated `bin/<module.exec>` bootstrap created by `openvcs build`.
|
|
92
146
|
|
|
93
147
|
Interactive theme plugin scaffold:
|
|
94
148
|
|
|
@@ -104,8 +158,10 @@ In a generated code plugin folder:
|
|
|
104
158
|
npm run build
|
|
105
159
|
```
|
|
106
160
|
|
|
107
|
-
This runs `openvcs build`, which executes `scripts["build:plugin"]
|
|
108
|
-
|
|
161
|
+
This runs `openvcs build`, which executes `scripts["build:plugin"]`, expects your
|
|
162
|
+
compiled plugin author module at `bin/plugin.js`, and then generates the SDK-owned
|
|
163
|
+
`bin/<module.exec>` bootstrap that imports `./plugin.js`, applies `PluginDefinition`,
|
|
164
|
+
invokes `OnPluginStart()`, and starts the runtime.
|
|
109
165
|
|
|
110
166
|
Theme-only plugins can also run `npm run build`; the command exits successfully
|
|
111
167
|
without producing `bin/` output.
|
|
@@ -135,6 +191,9 @@ Generated code plugin scripts use this split by default:
|
|
|
135
191
|
}
|
|
136
192
|
```
|
|
137
193
|
|
|
194
|
+
For code plugins, reserve `bin/plugin.js` for the compiled author module and point
|
|
195
|
+
`module.exec` at a different bootstrap filename such as `openvcs-plugin.js`.
|
|
196
|
+
|
|
138
197
|
`.ovcsp` is a gzip-compressed tar archive (`tar.gz`) that contains a top-level
|
|
139
198
|
`<plugin-id>/` directory with `openvcs.plugin.json` and plugin runtime assets.
|
|
140
199
|
|
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
|
-
|
|
126
|
-
|
|
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.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
|
}
|
|
@@ -218,7 +218,7 @@ function writeModuleTemplate(answers) {
|
|
|
218
218
|
},
|
|
219
219
|
include: ["src/**/*.ts"],
|
|
220
220
|
});
|
|
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 {
|
|
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");
|
|
222
222
|
}
|
|
223
223
|
function writeThemeTemplate(answers) {
|
|
224
224
|
writeCommonFiles(answers);
|
|
@@ -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
|
+
}
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PluginRuntime, PluginRuntimeTransport } from './contracts';
|
|
2
2
|
export type { CreatePluginRuntimeOptions, PluginRuntime, PluginRuntimeContext, PluginRuntimeTransport, } from './contracts';
|
|
3
|
-
export {
|
|
3
|
+
export type { BootstrapPluginModuleOptions, OnPluginStartHandler, PluginBootstrapModule, PluginModuleDefinition, } from './registration';
|
|
4
|
+
export { createDefaultPluginDelegates, createRuntimeDispatcher } from './dispatcher';
|
|
4
5
|
export { isPluginFailure, pluginError } from './errors';
|
|
6
|
+
export { createPluginRuntime } from './factory';
|
|
5
7
|
export { createHost } from './host';
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
+
export { bootstrapPluginModule, createRegisteredPluginRuntime, } from './registration';
|
|
9
|
+
export { VcsDelegateBase } from './vcs-delegate-base';
|
|
10
|
+
export type { VcsDelegateAssignments } from './vcs-delegate-metadata';
|
|
8
11
|
/** Starts a previously created plugin runtime on process stdio. */
|
|
9
12
|
export declare function startPluginRuntime(runtime: PluginRuntime, transport?: PluginRuntimeTransport): void;
|
package/lib/runtime/index.js
CHANGED
|
@@ -2,165 +2,24 @@
|
|
|
2
2
|
// Copyright © 2025-2026 OpenVCS Contributors
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
-
exports.createHost = exports.pluginError = exports.isPluginFailure = exports.createDefaultPluginDelegates = void 0;
|
|
6
|
-
exports.createPluginRuntime = createPluginRuntime;
|
|
5
|
+
exports.VcsDelegateBase = exports.createRegisteredPluginRuntime = exports.bootstrapPluginModule = exports.createHost = exports.createPluginRuntime = exports.pluginError = exports.isPluginFailure = exports.createRuntimeDispatcher = exports.createDefaultPluginDelegates = void 0;
|
|
7
6
|
exports.startPluginRuntime = startPluginRuntime;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
var dispatcher_2 = require("./dispatcher");
|
|
12
|
-
Object.defineProperty(exports, "createDefaultPluginDelegates", { enumerable: true, get: function () { return dispatcher_2.createDefaultPluginDelegates; } });
|
|
7
|
+
var dispatcher_1 = require("./dispatcher");
|
|
8
|
+
Object.defineProperty(exports, "createDefaultPluginDelegates", { enumerable: true, get: function () { return dispatcher_1.createDefaultPluginDelegates; } });
|
|
9
|
+
Object.defineProperty(exports, "createRuntimeDispatcher", { enumerable: true, get: function () { return dispatcher_1.createRuntimeDispatcher; } });
|
|
13
10
|
var errors_1 = require("./errors");
|
|
14
11
|
Object.defineProperty(exports, "isPluginFailure", { enumerable: true, get: function () { return errors_1.isPluginFailure; } });
|
|
15
12
|
Object.defineProperty(exports, "pluginError", { enumerable: true, get: function () { return errors_1.pluginError; } });
|
|
16
|
-
var
|
|
17
|
-
Object.defineProperty(exports, "
|
|
18
|
-
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const runtime = {
|
|
26
|
-
start(transport = defaultTransport()) {
|
|
27
|
-
if (started) {
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
started = true;
|
|
31
|
-
currentTransport = transport;
|
|
32
|
-
transport.stdin.on('data', (chunk) => {
|
|
33
|
-
runtime.consumeChunk(chunk);
|
|
34
|
-
});
|
|
35
|
-
transport.stdin.on('error', () => {
|
|
36
|
-
process.exit(1);
|
|
37
|
-
});
|
|
38
|
-
options.onStart?.();
|
|
39
|
-
},
|
|
40
|
-
stop() {
|
|
41
|
-
if (!started) {
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
started = false;
|
|
45
|
-
processing = processing
|
|
46
|
-
.catch(async (error) => {
|
|
47
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
48
|
-
console.error(`[runtime] shutdown error: ${errorMessage}`);
|
|
49
|
-
const err = error instanceof Error ? error : new Error(errorMessage);
|
|
50
|
-
await options.onShutdown?.(err);
|
|
51
|
-
})
|
|
52
|
-
.then(async () => {
|
|
53
|
-
await options.onShutdown?.();
|
|
54
|
-
});
|
|
55
|
-
currentTransport = null;
|
|
56
|
-
},
|
|
57
|
-
consumeChunk(chunk) {
|
|
58
|
-
if (!started) {
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const lock = chunkLock.then(() => {
|
|
62
|
-
buffer = Buffer.concat([buffer, normalizeChunk(chunk)]);
|
|
63
|
-
const parsed = (0, transport_1.parseFramedMessages)(buffer);
|
|
64
|
-
buffer = parsed.remainder;
|
|
65
|
-
for (const request of parsed.messages) {
|
|
66
|
-
processing = processing
|
|
67
|
-
.then(async () => {
|
|
68
|
-
await runtime.dispatchRequest(request);
|
|
69
|
-
})
|
|
70
|
-
.catch((error) => {
|
|
71
|
-
const message = error instanceof Error ? error.message : String(error || 'unknown plugin processing error');
|
|
72
|
-
const host = createRuntimeHost(currentTransport, options.logTarget);
|
|
73
|
-
host.error(message);
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
chunkLock = lock;
|
|
78
|
-
},
|
|
79
|
-
async dispatchRequest(request) {
|
|
80
|
-
const id = request.id;
|
|
81
|
-
const host = createRuntimeHost(currentTransport, options.logTarget);
|
|
82
|
-
const method = asTrimmedString(request.method);
|
|
83
|
-
const validationErrors = [];
|
|
84
|
-
if (!method)
|
|
85
|
-
validationErrors.push('missing method');
|
|
86
|
-
if (typeof id !== 'number' && typeof id !== 'string')
|
|
87
|
-
validationErrors.push(`invalid id type: ${typeof id}`);
|
|
88
|
-
if (validationErrors.length > 0) {
|
|
89
|
-
host.error(`invalid request: ${validationErrors.join(', ')}`);
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
const dispatcher = (0, dispatcher_1.createRuntimeDispatcher)(options, host, {
|
|
93
|
-
async sendResult(requestId, result) {
|
|
94
|
-
await sendMessage(currentTransport, {
|
|
95
|
-
jsonrpc: '2.0',
|
|
96
|
-
id: requestId,
|
|
97
|
-
result,
|
|
98
|
-
});
|
|
99
|
-
},
|
|
100
|
-
async sendError(requestId, code, message, data) {
|
|
101
|
-
await sendMessage(currentTransport, {
|
|
102
|
-
jsonrpc: '2.0',
|
|
103
|
-
id: requestId,
|
|
104
|
-
error: {
|
|
105
|
-
code,
|
|
106
|
-
message,
|
|
107
|
-
...(data == null ? {} : { data }),
|
|
108
|
-
},
|
|
109
|
-
});
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
const methodName = method;
|
|
113
|
-
const params = asRecord(request.params) ?? {};
|
|
114
|
-
const requestId = id;
|
|
115
|
-
await dispatcher(requestId, methodName, params);
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
return runtime;
|
|
119
|
-
}
|
|
13
|
+
var factory_1 = require("./factory");
|
|
14
|
+
Object.defineProperty(exports, "createPluginRuntime", { enumerable: true, get: function () { return factory_1.createPluginRuntime; } });
|
|
15
|
+
var host_1 = require("./host");
|
|
16
|
+
Object.defineProperty(exports, "createHost", { enumerable: true, get: function () { return host_1.createHost; } });
|
|
17
|
+
var registration_1 = require("./registration");
|
|
18
|
+
Object.defineProperty(exports, "bootstrapPluginModule", { enumerable: true, get: function () { return registration_1.bootstrapPluginModule; } });
|
|
19
|
+
Object.defineProperty(exports, "createRegisteredPluginRuntime", { enumerable: true, get: function () { return registration_1.createRegisteredPluginRuntime; } });
|
|
20
|
+
var vcs_delegate_base_1 = require("./vcs-delegate-base");
|
|
21
|
+
Object.defineProperty(exports, "VcsDelegateBase", { enumerable: true, get: function () { return vcs_delegate_base_1.VcsDelegateBase; } });
|
|
120
22
|
/** Starts a previously created plugin runtime on process stdio. */
|
|
121
23
|
function startPluginRuntime(runtime, transport) {
|
|
122
24
|
runtime.start(transport);
|
|
123
25
|
}
|
|
124
|
-
/** Returns the default stdio transport used by the runtime. */
|
|
125
|
-
function defaultTransport() {
|
|
126
|
-
return {
|
|
127
|
-
stdin: process.stdin,
|
|
128
|
-
stdout: process.stdout,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
/** Sends one JSON-RPC response or notification through the active transport. */
|
|
132
|
-
async function sendMessage(transport, value) {
|
|
133
|
-
await (0, transport_1.writeFramedMessage)((transport ?? defaultTransport()).stdout, value);
|
|
134
|
-
}
|
|
135
|
-
/** Builds the host helper for the currently active transport. */
|
|
136
|
-
function createRuntimeHost(transport, logTarget) {
|
|
137
|
-
return (0, host_1.createHost)(async (method, params) => {
|
|
138
|
-
await sendMessage(transport, {
|
|
139
|
-
jsonrpc: '2.0',
|
|
140
|
-
method,
|
|
141
|
-
params,
|
|
142
|
-
});
|
|
143
|
-
}, { logTarget });
|
|
144
|
-
}
|
|
145
|
-
/** Type guard that returns true if the value is a valid RequestParams object. */
|
|
146
|
-
function isRequestParams(value) {
|
|
147
|
-
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
return true;
|
|
151
|
-
}
|
|
152
|
-
/** Coerces unknown request method values into trimmed strings. Returns null for non-strings. */
|
|
153
|
-
function asTrimmedString(value) {
|
|
154
|
-
return typeof value === 'string' ? value.trim() : null;
|
|
155
|
-
}
|
|
156
|
-
/** Coerces unknown params to a Record, returning null for invalid input. */
|
|
157
|
-
function asRecord(value) {
|
|
158
|
-
if (isRequestParams(value)) {
|
|
159
|
-
return value;
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
/** Normalizes incoming data chunks to UTF-8 buffers. */
|
|
164
|
-
function normalizeChunk(chunk) {
|
|
165
|
-
return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
|
|
166
|
-
}
|