@openvcs/sdk 0.2.4 → 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.
- package/README.md +16 -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 +5 -4
- package/lib/runtime/index.js +11 -154
- package/lib/runtime/registration.d.ts +39 -0
- package/lib/runtime/registration.js +37 -0
- package/package.json +1 -1
- 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 +12 -177
- package/src/lib/runtime/registration.ts +93 -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/README.md
CHANGED
|
@@ -63,32 +63,34 @@ 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
|
|
|
83
84
|
Runtime and protocol imports are exposed as npm subpaths:
|
|
84
85
|
|
|
85
86
|
```ts
|
|
86
|
-
import {
|
|
87
|
+
import { pluginError } from '@openvcs/sdk/runtime';
|
|
87
88
|
import type { PluginDelegates, VcsDelegates } from '@openvcs/sdk/types';
|
|
88
89
|
```
|
|
89
90
|
|
|
90
91
|
The runtime handles stdio framing, JSON-RPC request dispatch, host notifications,
|
|
91
|
-
default `plugin.*` handlers,
|
|
92
|
+
default `plugin.*` handlers, exact-method delegate registration for `vcs.*`, and
|
|
93
|
+
the generated `bin/<module.exec>` bootstrap created by `openvcs build`.
|
|
92
94
|
|
|
93
95
|
Interactive theme plugin scaffold:
|
|
94
96
|
|
|
@@ -104,8 +106,10 @@ In a generated code plugin folder:
|
|
|
104
106
|
npm run build
|
|
105
107
|
```
|
|
106
108
|
|
|
107
|
-
This runs `openvcs build`, which executes `scripts["build:plugin"]
|
|
108
|
-
|
|
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.
|
|
109
113
|
|
|
110
114
|
Theme-only plugins can also run `npm run build`; the command exits successfully
|
|
111
115
|
without producing `bin/` output.
|
|
@@ -135,6 +139,9 @@ Generated code plugin scripts use this split by default:
|
|
|
135
139
|
}
|
|
136
140
|
```
|
|
137
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
|
+
|
|
138
145
|
`.ovcsp` is a gzip-compressed tar archive (`tar.gz`) that contains a top-level
|
|
139
146
|
`<plugin-id>/` directory with `openvcs.plugin.json` and plugin runtime assets.
|
|
140
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
|
-
|
|
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,10 @@
|
|
|
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 declare function createPluginRuntime(options?: CreatePluginRuntimeOptions): PluginRuntime;
|
|
8
|
+
export { bootstrapPluginModule, createRegisteredPluginRuntime, } from './registration';
|
|
8
9
|
/** Starts a previously created plugin runtime on process stdio. */
|
|
9
10
|
export declare function startPluginRuntime(runtime: PluginRuntime, transport?: PluginRuntimeTransport): void;
|
package/lib/runtime/index.js
CHANGED
|
@@ -2,165 +2,22 @@
|
|
|
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.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
|
-
let currentTransport = null;
|
|
24
|
-
let chunkLock = Promise.resolve();
|
|
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; } });
|
|
120
20
|
/** Starts a previously created plugin runtime on process stdio. */
|
|
121
21
|
function startPluginRuntime(runtime, transport) {
|
|
122
22
|
runtime.start(transport);
|
|
123
23
|
}
|
|
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
|
-
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PluginDelegates, PluginImplements, VcsDelegates } from '../types';
|
|
2
|
+
import type { PluginRuntime, PluginRuntimeContext, PluginRuntimeTransport } from './contracts';
|
|
3
|
+
/** Describes the plugin module startup hook invoked by the generated bootstrap. */
|
|
4
|
+
export type OnPluginStartHandler = () => void | Promise<void>;
|
|
5
|
+
/** Describes runtime-wide options applied by the generated bootstrap. */
|
|
6
|
+
export interface PluginModuleDefinition {
|
|
7
|
+
/** Stores optional `plugin.*` delegates contributed by the plugin module. */
|
|
8
|
+
plugin?: PluginDelegates<PluginRuntimeContext>;
|
|
9
|
+
/** Stores optional `vcs.*` delegates contributed by the plugin module. */
|
|
10
|
+
vcs?: VcsDelegates<PluginRuntimeContext>;
|
|
11
|
+
/** Stores optional capability overrides for `plugin.initialize`. */
|
|
12
|
+
implements?: Partial<PluginImplements>;
|
|
13
|
+
/** Stores the `host.log` target emitted by the runtime. */
|
|
14
|
+
logTarget?: string;
|
|
15
|
+
/** Stores the timeout in milliseconds for request handlers. */
|
|
16
|
+
timeout?: number;
|
|
17
|
+
/** Called during stop() after pending operations complete. */
|
|
18
|
+
onShutdown?: (error?: Error) => void | Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** Describes one imported plugin module consumed by the generated bootstrap. */
|
|
21
|
+
export interface PluginBootstrapModule {
|
|
22
|
+
/** Stores the plugin author's declarative runtime definition. */
|
|
23
|
+
PluginDefinition?: PluginModuleDefinition;
|
|
24
|
+
/** Stores the plugin author's startup hook. */
|
|
25
|
+
OnPluginStart?: OnPluginStartHandler;
|
|
26
|
+
}
|
|
27
|
+
/** Describes the generated bootstrap inputs used to import and start a plugin. */
|
|
28
|
+
export interface BootstrapPluginModuleOptions {
|
|
29
|
+
/** Imports the plugin author's compiled runtime module. */
|
|
30
|
+
importPluginModule: () => Promise<PluginBootstrapModule>;
|
|
31
|
+
/** Stores the plugin module path for error messages. */
|
|
32
|
+
modulePath: string;
|
|
33
|
+
/** Overrides the transport used by the runtime loop. */
|
|
34
|
+
transport?: PluginRuntimeTransport;
|
|
35
|
+
}
|
|
36
|
+
/** Creates a runtime from one declarative plugin module definition. */
|
|
37
|
+
export declare function createRegisteredPluginRuntime(definition?: PluginModuleDefinition): PluginRuntime;
|
|
38
|
+
/** Imports the author module, applies its definition, runs `OnPluginStart`, and starts the runtime loop. */
|
|
39
|
+
export declare function bootstrapPluginModule(options: BootstrapPluginModuleOptions): Promise<PluginRuntime>;
|
|
@@ -0,0 +1,37 @@
|
|
|
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.createRegisteredPluginRuntime = createRegisteredPluginRuntime;
|
|
6
|
+
exports.bootstrapPluginModule = bootstrapPluginModule;
|
|
7
|
+
const factory_1 = require("./factory");
|
|
8
|
+
/** Creates a runtime from one declarative plugin module definition. */
|
|
9
|
+
function createRegisteredPluginRuntime(definition = {}) {
|
|
10
|
+
const options = {
|
|
11
|
+
plugin: definition.plugin,
|
|
12
|
+
vcs: definition.vcs,
|
|
13
|
+
implements: definition.implements,
|
|
14
|
+
logTarget: definition.logTarget,
|
|
15
|
+
timeout: definition.timeout,
|
|
16
|
+
onShutdown: definition.onShutdown,
|
|
17
|
+
};
|
|
18
|
+
return (0, factory_1.createPluginRuntime)(options);
|
|
19
|
+
}
|
|
20
|
+
/** Imports the author module, applies its definition, runs `OnPluginStart`, and starts the runtime loop. */
|
|
21
|
+
async function bootstrapPluginModule(options) {
|
|
22
|
+
const pluginModule = await options.importPluginModule();
|
|
23
|
+
const onPluginStart = pluginModule.OnPluginStart;
|
|
24
|
+
if (typeof onPluginStart !== 'function') {
|
|
25
|
+
throw new Error(`plugin module '${options.modulePath}' must export OnPluginStart()`);
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await onPluginStart();
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
throw new Error(`plugin startup failed: ${message}`);
|
|
33
|
+
}
|
|
34
|
+
const runtime = createRegisteredPluginRuntime(pluginModule.PluginDefinition);
|
|
35
|
+
runtime.start(options.transport);
|
|
36
|
+
return runtime;
|
|
37
|
+
}
|