@openvcs/sdk 0.2.2 → 0.2.4
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 +76 -7
- package/lib/build.d.ts +28 -0
- package/lib/build.js +188 -0
- package/lib/cli.js +21 -2
- package/lib/dist.d.ts +4 -7
- package/lib/dist.js +67 -113
- package/lib/init.d.ts +2 -0
- package/lib/init.js +13 -8
- package/lib/runtime/contracts.d.ts +45 -0
- package/lib/runtime/contracts.js +4 -0
- package/lib/runtime/dispatcher.d.ts +16 -0
- package/lib/runtime/dispatcher.js +133 -0
- package/lib/runtime/errors.d.ts +5 -0
- package/lib/runtime/errors.js +26 -0
- package/lib/runtime/host.d.ts +10 -0
- package/lib/runtime/host.js +48 -0
- package/lib/runtime/index.d.ts +9 -0
- package/lib/runtime/index.js +166 -0
- package/lib/runtime/transport.d.ts +14 -0
- package/lib/runtime/transport.js +72 -0
- package/lib/types/host.d.ts +57 -0
- package/lib/types/host.js +4 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.js +22 -0
- package/lib/types/plugin.d.ts +56 -0
- package/lib/types/plugin.js +4 -0
- package/lib/types/protocol.d.ts +77 -0
- package/lib/types/protocol.js +13 -0
- package/lib/types/vcs.d.ts +459 -0
- package/lib/types/vcs.js +4 -0
- package/package.json +16 -3
- package/src/lib/build.ts +229 -0
- package/src/lib/cli.ts +21 -2
- package/src/lib/dist.ts +76 -128
- package/src/lib/init.ts +13 -8
- package/src/lib/runtime/contracts.ts +52 -0
- package/src/lib/runtime/dispatcher.ts +185 -0
- package/src/lib/runtime/errors.ts +27 -0
- package/src/lib/runtime/host.ts +72 -0
- package/src/lib/runtime/index.ts +201 -0
- package/src/lib/runtime/transport.ts +93 -0
- package/src/lib/types/host.ts +71 -0
- package/src/lib/types/index.ts +7 -0
- package/src/lib/types/plugin.ts +110 -0
- package/src/lib/types/protocol.ts +97 -0
- package/src/lib/types/vcs.ts +579 -0
- package/test/build.test.js +95 -0
- package/test/cli.test.js +37 -0
- package/test/dist.test.js +239 -15
- package/test/init.test.js +25 -0
- package/test/runtime.test.js +118 -0
package/src/lib/build.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Copyright © 2025-2026 OpenVCS Contributors
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
import { isPathInside } from "./fs-utils";
|
|
9
|
+
|
|
10
|
+
type UsageError = Error & { code?: string };
|
|
11
|
+
|
|
12
|
+
/** CLI arguments for `openvcs build`. */
|
|
13
|
+
export interface BuildArgs {
|
|
14
|
+
pluginDir: string;
|
|
15
|
+
verbose: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Trimmed manifest details used by build and dist flows. */
|
|
19
|
+
export interface ManifestInfo {
|
|
20
|
+
pluginId: string;
|
|
21
|
+
moduleExec: string | undefined;
|
|
22
|
+
entry: string | undefined;
|
|
23
|
+
manifestPath: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface CommandResult {
|
|
27
|
+
status: number | null;
|
|
28
|
+
error?: Error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PackageScripts {
|
|
32
|
+
[scriptName: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Returns the npm executable name for the current platform. */
|
|
36
|
+
export function npmExecutable(): string {
|
|
37
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Formats help text for the build command. */
|
|
41
|
+
export function buildUsage(commandName = "openvcs"): string {
|
|
42
|
+
return `${commandName} build [args]\n\n --plugin-dir <path> Plugin repository root (contains openvcs.plugin.json)\n -V, --verbose Enable verbose output\n`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parses `openvcs build` arguments. */
|
|
46
|
+
export function parseBuildArgs(args: string[]): BuildArgs {
|
|
47
|
+
let pluginDir = process.cwd();
|
|
48
|
+
let verbose = false;
|
|
49
|
+
|
|
50
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
51
|
+
const arg = args[index];
|
|
52
|
+
if (arg === "--plugin-dir") {
|
|
53
|
+
index += 1;
|
|
54
|
+
if (index >= args.length) {
|
|
55
|
+
throw new Error("missing value for --plugin-dir");
|
|
56
|
+
}
|
|
57
|
+
pluginDir = args[index] as string;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (arg === "-V" || arg === "--verbose") {
|
|
61
|
+
verbose = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (arg === "--help") {
|
|
65
|
+
const error = new Error(buildUsage()) as UsageError;
|
|
66
|
+
error.code = "USAGE";
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`unknown flag: ${arg}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
pluginDir: path.resolve(pluginDir),
|
|
74
|
+
verbose,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Reads and validates the plugin manifest. */
|
|
79
|
+
export function readManifest(pluginDir: string): ManifestInfo {
|
|
80
|
+
const manifestPath = path.join(pluginDir, "openvcs.plugin.json");
|
|
81
|
+
let manifestRaw: string;
|
|
82
|
+
let manifestFd: number | undefined;
|
|
83
|
+
let manifest: unknown;
|
|
84
|
+
try {
|
|
85
|
+
manifestFd = fs.openSync(manifestPath, "r");
|
|
86
|
+
const manifestStat = fs.fstatSync(manifestFd);
|
|
87
|
+
if (!manifestStat.isFile()) {
|
|
88
|
+
throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
|
|
89
|
+
}
|
|
90
|
+
manifestRaw = fs.readFileSync(manifestFd, "utf8");
|
|
91
|
+
} catch (error: unknown) {
|
|
92
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
93
|
+
throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
} finally {
|
|
97
|
+
if (typeof manifestFd === "number") {
|
|
98
|
+
fs.closeSync(manifestFd);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
manifest = JSON.parse(manifestRaw);
|
|
104
|
+
} catch (error: unknown) {
|
|
105
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
106
|
+
throw new Error(`parse ${manifestPath}: ${detail}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const pluginId =
|
|
110
|
+
typeof (manifest as { id?: unknown }).id === "string"
|
|
111
|
+
? ((manifest as { id: string }).id.trim() as string)
|
|
112
|
+
: "";
|
|
113
|
+
if (!pluginId) {
|
|
114
|
+
throw new Error(`manifest ${manifestPath} is missing a string 'id'`);
|
|
115
|
+
}
|
|
116
|
+
if (pluginId === "." || pluginId === ".." || pluginId.includes("/") || pluginId.includes("\\")) {
|
|
117
|
+
throw new Error(`manifest id must not contain path separators: ${pluginId}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const moduleValue = (manifest as { module?: { exec?: unknown } }).module;
|
|
121
|
+
const moduleExec = typeof moduleValue?.exec === "string" ? moduleValue.exec.trim() : undefined;
|
|
122
|
+
|
|
123
|
+
const entryValue = (manifest as { entry?: unknown }).entry;
|
|
124
|
+
const entry = typeof entryValue === "string" ? entryValue.trim() : undefined;
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
pluginId,
|
|
128
|
+
moduleExec,
|
|
129
|
+
entry,
|
|
130
|
+
manifestPath,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Verifies that a declared module entry resolves to a real file under `bin/`. */
|
|
135
|
+
export function validateDeclaredModuleExec(pluginDir: string, moduleExec: string | undefined): void {
|
|
136
|
+
if (!moduleExec) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const normalizedExec = moduleExec.trim();
|
|
141
|
+
const lowered = normalizedExec.toLowerCase();
|
|
142
|
+
if (!lowered.endsWith(".js") && !lowered.endsWith(".mjs") && !lowered.endsWith(".cjs")) {
|
|
143
|
+
throw new Error(`manifest exec must end with .js/.mjs/.cjs (Node runtime): ${moduleExec}`);
|
|
144
|
+
}
|
|
145
|
+
if (path.isAbsolute(normalizedExec)) {
|
|
146
|
+
throw new Error(`manifest module.exec must be a relative path under bin/: ${moduleExec}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const binDir = path.resolve(pluginDir, "bin");
|
|
150
|
+
const targetPath = path.resolve(binDir, normalizedExec);
|
|
151
|
+
if (!isPathInside(binDir, targetPath) || targetPath === binDir) {
|
|
152
|
+
throw new Error(`manifest module.exec must point to a file under bin/: ${moduleExec}`);
|
|
153
|
+
}
|
|
154
|
+
if (!fs.existsSync(targetPath) || !fs.lstatSync(targetPath).isFile()) {
|
|
155
|
+
throw new Error(`module entrypoint not found at ${targetPath}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Returns whether the plugin repository has a `package.json`. */
|
|
160
|
+
export function hasPackageJson(pluginDir: string): boolean {
|
|
161
|
+
const packageJsonPath = path.join(pluginDir, "package.json");
|
|
162
|
+
return fs.existsSync(packageJsonPath) && fs.lstatSync(packageJsonPath).isFile();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Runs a command in the given directory with optional verbose logging. */
|
|
166
|
+
export function runCommand(program: string, args: string[], cwd: string, verbose: boolean): void {
|
|
167
|
+
if (verbose) {
|
|
168
|
+
process.stderr.write(`Running command in ${cwd}: ${program} ${args.join(" ")}\n`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = spawnSync(program, args, {
|
|
172
|
+
cwd,
|
|
173
|
+
stdio: ["ignore", verbose ? "inherit" : "ignore", "inherit"],
|
|
174
|
+
}) as CommandResult;
|
|
175
|
+
|
|
176
|
+
if (result.error) {
|
|
177
|
+
throw new Error(`failed to spawn '${program}' in ${cwd}: ${result.error.message}`);
|
|
178
|
+
}
|
|
179
|
+
if (result.status === 0) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw new Error(`command failed (${program} ${args.join(" ")}), exit code ${result.status}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Reads `package.json` scripts for the plugin, if present. */
|
|
187
|
+
function readPackageScripts(pluginDir: string): PackageScripts {
|
|
188
|
+
const packageJsonPath = path.join(pluginDir, "package.json");
|
|
189
|
+
let packageData: unknown;
|
|
190
|
+
try {
|
|
191
|
+
packageData = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
192
|
+
} catch (error: unknown) {
|
|
193
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
194
|
+
throw new Error(`code plugins must include package.json: ${packageJsonPath}`);
|
|
195
|
+
}
|
|
196
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
197
|
+
throw new Error(`parse ${packageJsonPath}: ${detail}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const scripts = (packageData as { scripts?: unknown }).scripts;
|
|
201
|
+
if (typeof scripts !== "object" || scripts === null) {
|
|
202
|
+
return {};
|
|
203
|
+
}
|
|
204
|
+
return scripts as PackageScripts;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Builds a plugin's runtime assets when it declares a code module. */
|
|
208
|
+
export function buildPluginAssets(parsedArgs: BuildArgs): ManifestInfo {
|
|
209
|
+
const manifest = readManifest(parsedArgs.pluginDir);
|
|
210
|
+
if (!manifest.moduleExec) {
|
|
211
|
+
return manifest;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!hasPackageJson(parsedArgs.pluginDir)) {
|
|
215
|
+
throw new Error(`code plugins must include package.json: ${path.join(parsedArgs.pluginDir, "package.json")}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const scripts = readPackageScripts(parsedArgs.pluginDir);
|
|
219
|
+
const buildScript = scripts["build:plugin"];
|
|
220
|
+
if (typeof buildScript !== "string" || buildScript.trim() === "") {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`code plugins must define scripts[\"build:plugin\"] in ${path.join(parsedArgs.pluginDir, "package.json")}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
runCommand(npmExecutable(), ["run", "build:plugin"], parsedArgs.pluginDir, parsedArgs.verbose);
|
|
227
|
+
validateDeclaredModuleExec(parsedArgs.pluginDir, manifest.moduleExec);
|
|
228
|
+
return manifest;
|
|
229
|
+
}
|
package/src/lib/cli.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildPluginAssets, buildUsage, parseBuildArgs } from "./build";
|
|
1
2
|
import { bundlePlugin, distUsage, parseDistArgs } from "./dist";
|
|
2
3
|
import { initUsage, runInitCommand } from "./init";
|
|
3
4
|
|
|
@@ -17,7 +18,7 @@ function hasCode(error: unknown, code: string): boolean {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function usage(): string {
|
|
20
|
-
return "Usage: openvcs <command> [options]\n\nCommands:\n dist [args] Package plugin into .ovcsp\n init [--theme] [dir] Interactively scaffold a plugin project\n -v, --version Show version information\n\nDist args:\n --plugin-dir <path> Plugin root containing openvcs.plugin.json\n --out <path> Output directory (default: ./dist)\n --no-npm-deps Skip npm dependency bundling\n -V, --verbose Verbose output\n";
|
|
21
|
+
return "Usage: openvcs <command> [options]\n\nCommands:\n build [args] Build plugin runtime assets\n dist [args] Package plugin into .ovcsp\n init [--theme] [dir] Interactively scaffold a plugin project\n -v, --version Show version information\n\nBuild args:\n --plugin-dir <path> Plugin root containing openvcs.plugin.json\n -V, --verbose Verbose output\n\nDist args:\n --plugin-dir <path> Plugin root containing openvcs.plugin.json\n --out <path> Output directory (default: ./dist)\n --no-build Skip plugin build before packaging\n --no-npm-deps Skip npm dependency bundling\n -V, --verbose Verbose output\n";
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export async function runCli(args: string[]): Promise<void> {
|
|
@@ -27,7 +28,7 @@ export async function runCli(args: string[]): Promise<void> {
|
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
if (args
|
|
31
|
+
if (args[0] === "-v" || args[0] === "--version") {
|
|
31
32
|
process.stdout.write(`openvcs ${packageJson.version}\n`);
|
|
32
33
|
return;
|
|
33
34
|
}
|
|
@@ -56,6 +57,24 @@ export async function runCli(args: string[]): Promise<void> {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
if (command === "build") {
|
|
61
|
+
if (rest.includes("--help")) {
|
|
62
|
+
process.stdout.write(buildUsage());
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const parsed = parseBuildArgs(rest);
|
|
67
|
+
const manifest = buildPluginAssets(parsed);
|
|
68
|
+
process.stdout.write(`${manifest.pluginId}\n`);
|
|
69
|
+
return;
|
|
70
|
+
} catch (error: unknown) {
|
|
71
|
+
if (hasCode(error, "USAGE")) {
|
|
72
|
+
throw new Error(buildUsage());
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
if (command === "init") {
|
|
60
79
|
if (rest.includes("--help")) {
|
|
61
80
|
process.stdout.write(initUsage());
|
package/src/lib/dist.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
3
|
import tar = require("tar");
|
|
4
|
+
import {
|
|
5
|
+
buildPluginAssets,
|
|
6
|
+
hasPackageJson,
|
|
7
|
+
ManifestInfo,
|
|
8
|
+
npmExecutable,
|
|
9
|
+
readManifest,
|
|
10
|
+
runCommand,
|
|
11
|
+
validateDeclaredModuleExec,
|
|
12
|
+
} from "./build";
|
|
5
13
|
import {
|
|
6
14
|
copyDirectoryRecursiveStrict,
|
|
7
15
|
copyFileStrict,
|
|
@@ -19,25 +27,11 @@ interface DistArgs {
|
|
|
19
27
|
outDir: string;
|
|
20
28
|
verbose: boolean;
|
|
21
29
|
noNpmDeps: boolean;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
interface ManifestInfo {
|
|
25
|
-
pluginId: string;
|
|
26
|
-
moduleExec: string | undefined;
|
|
27
|
-
manifestPath: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface CommandResult {
|
|
31
|
-
status: number | null;
|
|
32
|
-
error?: Error;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function npmExecutable(): string {
|
|
36
|
-
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
30
|
+
noBuild: boolean;
|
|
37
31
|
}
|
|
38
32
|
|
|
39
33
|
export function distUsage(commandName = "openvcs"): string {
|
|
40
|
-
return `${commandName} dist [args]\n\n --plugin-dir <path> Plugin repository root (contains openvcs.plugin.json)\n --out <path> Output directory (default: ./dist)\n --no-npm-deps Disable npm dependency bundling (enabled by default)\n -V, --verbose Enable verbose output\n`;
|
|
34
|
+
return `${commandName} dist [args]\n\n --plugin-dir <path> Plugin repository root (contains openvcs.plugin.json)\n --out <path> Output directory (default: ./dist)\n --no-build Skip the plugin build step before packaging\n --no-npm-deps Disable npm dependency bundling (enabled by default)\n -V, --verbose Enable verbose output\n`;
|
|
41
35
|
}
|
|
42
36
|
|
|
43
37
|
export function parseDistArgs(args: string[]): DistArgs {
|
|
@@ -45,6 +39,7 @@ export function parseDistArgs(args: string[]): DistArgs {
|
|
|
45
39
|
let outDir = "dist";
|
|
46
40
|
let verbose = false;
|
|
47
41
|
let noNpmDeps = false;
|
|
42
|
+
let noBuild = false;
|
|
48
43
|
|
|
49
44
|
for (let index = 0; index < args.length; index += 1) {
|
|
50
45
|
const arg = args[index];
|
|
@@ -68,6 +63,10 @@ export function parseDistArgs(args: string[]): DistArgs {
|
|
|
68
63
|
noNpmDeps = true;
|
|
69
64
|
continue;
|
|
70
65
|
}
|
|
66
|
+
if (arg === "--no-build") {
|
|
67
|
+
noBuild = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
71
70
|
if (arg === "-V" || arg === "--verbose") {
|
|
72
71
|
verbose = true;
|
|
73
72
|
continue;
|
|
@@ -85,110 +84,34 @@ export function parseDistArgs(args: string[]): DistArgs {
|
|
|
85
84
|
outDir: path.resolve(outDir),
|
|
86
85
|
verbose,
|
|
87
86
|
noNpmDeps,
|
|
87
|
+
noBuild,
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
function
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
let manifest: unknown;
|
|
96
|
-
try {
|
|
97
|
-
manifestFd = fs.openSync(manifestPath, "r");
|
|
98
|
-
const manifestStat = fs.fstatSync(manifestFd);
|
|
99
|
-
if (!manifestStat.isFile()) {
|
|
100
|
-
throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
|
|
101
|
-
}
|
|
102
|
-
manifestRaw = fs.readFileSync(manifestFd, "utf8");
|
|
103
|
-
} catch (error: unknown) {
|
|
104
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
105
|
-
throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
|
|
106
|
-
}
|
|
107
|
-
throw error;
|
|
108
|
-
} finally {
|
|
109
|
-
if (typeof manifestFd === "number") {
|
|
110
|
-
fs.closeSync(manifestFd);
|
|
111
|
-
}
|
|
91
|
+
function validateManifestEntry(pluginDir: string, entry: string): void {
|
|
92
|
+
const normalized = entry.trim();
|
|
93
|
+
if (path.isAbsolute(normalized)) {
|
|
94
|
+
throw new Error(`manifest entry must be a relative path: ${entry}`);
|
|
112
95
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
118
|
-
throw new Error(`parse ${manifestPath}: ${detail}`);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const pluginId =
|
|
122
|
-
typeof (manifest as { id?: unknown }).id === "string"
|
|
123
|
-
? ((manifest as { id: string }).id.trim() as string)
|
|
124
|
-
: "";
|
|
125
|
-
if (!pluginId) {
|
|
126
|
-
throw new Error(`manifest ${manifestPath} is missing a string 'id'`);
|
|
127
|
-
}
|
|
128
|
-
if (pluginId === "." || pluginId === ".." || pluginId.includes("/") || pluginId.includes("\\")) {
|
|
129
|
-
throw new Error(`manifest id must not contain path separators: ${pluginId}`);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const moduleValue = (manifest as { module?: { exec?: unknown } }).module;
|
|
133
|
-
const moduleExec = typeof moduleValue?.exec === "string" ? moduleValue.exec.trim() : undefined;
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
pluginId,
|
|
137
|
-
moduleExec,
|
|
138
|
-
manifestPath,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function validateDeclaredModuleExec(pluginDir: string, moduleExec: string | undefined): void {
|
|
143
|
-
if (!moduleExec) {
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const normalizedExec = moduleExec.trim();
|
|
148
|
-
const lowered = normalizedExec.toLowerCase();
|
|
149
|
-
if (!lowered.endsWith(".js") && !lowered.endsWith(".mjs") && !lowered.endsWith(".cjs")) {
|
|
150
|
-
throw new Error(`manifest exec must end with .js/.mjs/.cjs (Node runtime): ${moduleExec}`);
|
|
151
|
-
}
|
|
152
|
-
if (path.isAbsolute(normalizedExec)) {
|
|
153
|
-
throw new Error(`manifest module.exec must be a relative path under bin/: ${moduleExec}`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const binDir = path.resolve(pluginDir, "bin");
|
|
157
|
-
const targetPath = path.resolve(binDir, normalizedExec);
|
|
158
|
-
if (!isPathInside(binDir, targetPath) || targetPath === binDir) {
|
|
159
|
-
throw new Error(`manifest module.exec must point to a file under bin/: ${moduleExec}`);
|
|
96
|
+
const targetPath = path.resolve(pluginDir, normalized);
|
|
97
|
+
const pluginDirResolved = path.resolve(pluginDir);
|
|
98
|
+
if (!isPathInside(pluginDirResolved, targetPath) || targetPath === pluginDirResolved) {
|
|
99
|
+
throw new Error(`manifest entry must point to a file under the plugin directory: ${entry}`);
|
|
160
100
|
}
|
|
161
101
|
if (!fs.existsSync(targetPath) || !fs.lstatSync(targetPath).isFile()) {
|
|
162
|
-
throw new Error(`
|
|
102
|
+
throw new Error(`manifest entry file not found: ${entry}`);
|
|
163
103
|
}
|
|
164
104
|
}
|
|
165
105
|
|
|
166
|
-
function
|
|
167
|
-
const
|
|
168
|
-
|
|
106
|
+
function copyEntryDirectory(pluginDir: string, bundleDir: string, entry: string): void {
|
|
107
|
+
const normalized = entry.trim();
|
|
108
|
+
const entryDir = path.dirname(normalized);
|
|
109
|
+
const sourceDir = path.join(pluginDir, entryDir);
|
|
110
|
+
const destDir = path.join(bundleDir, entryDir);
|
|
111
|
+
copyDirectoryRecursiveStrict(sourceDir, destDir);
|
|
169
112
|
}
|
|
170
113
|
|
|
171
|
-
function
|
|
172
|
-
if (verbose) {
|
|
173
|
-
process.stderr.write(`Running command in ${cwd}: ${program} ${args.join(" ")}\n`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const result = spawnSync(program, args, {
|
|
177
|
-
cwd,
|
|
178
|
-
stdio: ["ignore", verbose ? "inherit" : "ignore", "inherit"],
|
|
179
|
-
}) as CommandResult;
|
|
180
|
-
|
|
181
|
-
if (result.error) {
|
|
182
|
-
throw new Error(`failed to spawn '${program}' in ${cwd}: ${result.error.message}`);
|
|
183
|
-
}
|
|
184
|
-
if (result.status === 0) {
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
throw new Error(`command failed (${program} ${args.join(" ")}), exit code ${result.status}`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function ensurePackageLock(pluginDir: string, verbose: boolean): void {
|
|
114
|
+
function ensurePackageLock(pluginDir: string, bundleDir: string, verbose: boolean): void {
|
|
192
115
|
if (!hasPackageJson(pluginDir)) {
|
|
193
116
|
return;
|
|
194
117
|
}
|
|
@@ -197,13 +120,16 @@ function ensurePackageLock(pluginDir: string, verbose: boolean): void {
|
|
|
197
120
|
return;
|
|
198
121
|
}
|
|
199
122
|
|
|
123
|
+
const packageJsonPath = path.join(pluginDir, "package.json");
|
|
124
|
+
copyFileStrict(packageJsonPath, path.join(bundleDir, "package.json"));
|
|
125
|
+
|
|
200
126
|
if (verbose) {
|
|
201
|
-
process.stderr.write(`Generating package-lock.json in
|
|
127
|
+
process.stderr.write(`Generating package-lock.json in staging\n`);
|
|
202
128
|
}
|
|
203
129
|
runCommand(
|
|
204
130
|
npmExecutable(),
|
|
205
131
|
["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"],
|
|
206
|
-
|
|
132
|
+
bundleDir,
|
|
207
133
|
verbose
|
|
208
134
|
);
|
|
209
135
|
}
|
|
@@ -215,12 +141,17 @@ function copyNpmFilesToStaging(pluginDir: string, bundleDir: string): void {
|
|
|
215
141
|
if (!fs.existsSync(packageJsonPath) || !fs.lstatSync(packageJsonPath).isFile()) {
|
|
216
142
|
throw new Error(`missing package.json at ${packageJsonPath}`);
|
|
217
143
|
}
|
|
218
|
-
if (!fs.existsSync(lockPath) || !fs.lstatSync(lockPath).isFile()) {
|
|
219
|
-
throw new Error(`missing package-lock.json at ${lockPath}`);
|
|
220
|
-
}
|
|
221
144
|
|
|
222
145
|
copyFileStrict(packageJsonPath, path.join(bundleDir, "package.json"));
|
|
223
|
-
|
|
146
|
+
|
|
147
|
+
const stagedLockPath = path.join(bundleDir, "package-lock.json");
|
|
148
|
+
if (fs.existsSync(stagedLockPath) && fs.lstatSync(stagedLockPath).isFile()) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (fs.existsSync(lockPath) && fs.lstatSync(lockPath).isFile()) {
|
|
153
|
+
copyFileStrict(lockPath, path.join(bundleDir, "package-lock.json"));
|
|
154
|
+
}
|
|
224
155
|
}
|
|
225
156
|
|
|
226
157
|
function rejectNativeAddonsRecursive(dirPath: string): void {
|
|
@@ -264,14 +195,21 @@ function installNpmDependencies(pluginDir: string, bundleDir: string, verbose: b
|
|
|
264
195
|
}
|
|
265
196
|
|
|
266
197
|
function copyIcon(pluginDir: string, bundleDir: string): void {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
198
|
+
const entries = fs.readdirSync(pluginDir, { withFileTypes: true });
|
|
199
|
+
const iconEntries = entries.filter((e) => {
|
|
200
|
+
if (!e.isFile()) return false;
|
|
201
|
+
const name = e.name.toLowerCase();
|
|
202
|
+
return name.startsWith("icon.") && ICON_EXTENSIONS.includes(name.slice(5));
|
|
203
|
+
});
|
|
204
|
+
for (const ext of ICON_EXTENSIONS) {
|
|
205
|
+
const found = iconEntries.find((e) => e.name.toLowerCase() === `icon.${ext}`);
|
|
206
|
+
if (found) {
|
|
207
|
+
copyFileStrict(
|
|
208
|
+
path.join(pluginDir, found.name),
|
|
209
|
+
path.join(bundleDir, found.name)
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
272
212
|
}
|
|
273
|
-
copyFileStrict(sourcePath, path.join(bundleDir, fileName));
|
|
274
|
-
return;
|
|
275
213
|
}
|
|
276
214
|
}
|
|
277
215
|
|
|
@@ -297,16 +235,21 @@ async function writeTarGz(outPath: string, baseDir: string, folderName: string):
|
|
|
297
235
|
}
|
|
298
236
|
|
|
299
237
|
export async function bundlePlugin(parsedArgs: DistArgs): Promise<string> {
|
|
300
|
-
const { pluginDir, outDir, verbose, noNpmDeps } = parsedArgs;
|
|
238
|
+
const { pluginDir, outDir, verbose, noNpmDeps, noBuild } = parsedArgs;
|
|
301
239
|
if (verbose) {
|
|
302
240
|
process.stderr.write(`Bundling plugin from: ${pluginDir}\n`);
|
|
303
241
|
}
|
|
304
242
|
|
|
305
|
-
const { pluginId, moduleExec, manifestPath } =
|
|
243
|
+
const { pluginId, moduleExec, entry, manifestPath } = noBuild
|
|
244
|
+
? readManifest(pluginDir)
|
|
245
|
+
: buildPluginAssets({ pluginDir, verbose });
|
|
306
246
|
const themesPath = path.join(pluginDir, "themes");
|
|
307
247
|
const hasThemes = fs.existsSync(themesPath) && fs.lstatSync(themesPath).isDirectory();
|
|
308
|
-
if (
|
|
309
|
-
|
|
248
|
+
if (entry) {
|
|
249
|
+
validateManifestEntry(pluginDir, entry);
|
|
250
|
+
}
|
|
251
|
+
if (!moduleExec && !hasThemes && !entry) {
|
|
252
|
+
throw new Error("manifest has no module.exec, entry, or themes/");
|
|
310
253
|
}
|
|
311
254
|
validateDeclaredModuleExec(pluginDir, moduleExec);
|
|
312
255
|
|
|
@@ -320,6 +263,10 @@ export async function bundlePlugin(parsedArgs: DistArgs): Promise<string> {
|
|
|
320
263
|
copyFileStrict(manifestPath, path.join(bundleDir, "openvcs.plugin.json"));
|
|
321
264
|
copyIcon(pluginDir, bundleDir);
|
|
322
265
|
|
|
266
|
+
if (entry) {
|
|
267
|
+
copyEntryDirectory(pluginDir, bundleDir, entry);
|
|
268
|
+
}
|
|
269
|
+
|
|
323
270
|
const sourceBinDir = path.join(pluginDir, "bin");
|
|
324
271
|
if (fs.existsSync(sourceBinDir) && fs.lstatSync(sourceBinDir).isDirectory()) {
|
|
325
272
|
copyDirectoryRecursiveStrict(sourceBinDir, path.join(bundleDir, "bin"));
|
|
@@ -329,7 +276,7 @@ export async function bundlePlugin(parsedArgs: DistArgs): Promise<string> {
|
|
|
329
276
|
}
|
|
330
277
|
|
|
331
278
|
if (!noNpmDeps && hasPackageJson(pluginDir)) {
|
|
332
|
-
ensurePackageLock(pluginDir, verbose);
|
|
279
|
+
ensurePackageLock(pluginDir, bundleDir, verbose);
|
|
333
280
|
installNpmDependencies(pluginDir, bundleDir, verbose);
|
|
334
281
|
}
|
|
335
282
|
|
|
@@ -352,5 +299,6 @@ export const __private = {
|
|
|
352
299
|
rejectNativeAddonsRecursive,
|
|
353
300
|
uniqueStagingDir,
|
|
354
301
|
validateDeclaredModuleExec,
|
|
302
|
+
validateManifestEntry,
|
|
355
303
|
writeTarGz,
|
|
356
304
|
};
|
package/src/lib/init.ts
CHANGED
|
@@ -236,12 +236,15 @@ function writeModuleTemplate(answers: InitAnswers): void {
|
|
|
236
236
|
private: true,
|
|
237
237
|
type: "module",
|
|
238
238
|
scripts: {
|
|
239
|
-
"build:
|
|
240
|
-
build: "
|
|
241
|
-
|
|
239
|
+
"build:plugin": "tsc -p tsconfig.json",
|
|
240
|
+
build: "openvcs build",
|
|
241
|
+
dist: "openvcs dist --plugin-dir . --out dist",
|
|
242
|
+
test: "openvcs dist --plugin-dir . --out dist --no-build --no-npm-deps",
|
|
242
243
|
},
|
|
243
|
-
|
|
244
|
+
dependencies: {
|
|
244
245
|
"@openvcs/sdk": `^${packageJson.version}`,
|
|
246
|
+
},
|
|
247
|
+
devDependencies: {
|
|
245
248
|
"@types/node": "^22.0.0",
|
|
246
249
|
typescript: "^5.8.2",
|
|
247
250
|
},
|
|
@@ -261,7 +264,7 @@ function writeModuleTemplate(answers: InitAnswers): void {
|
|
|
261
264
|
});
|
|
262
265
|
writeText(
|
|
263
266
|
path.join(answers.targetDir, "src", "plugin.ts"),
|
|
264
|
-
"
|
|
267
|
+
"// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\nimport { createPluginRuntime, startPluginRuntime } from '@openvcs/sdk/runtime';\n\nconst runtime = createPluginRuntime({\n plugin: {\n async 'plugin.init'(_params, context) {\n context.host.info('OpenVCS plugin started');\n return null;\n },\n },\n});\n\nstartPluginRuntime(runtime);\n"
|
|
265
268
|
);
|
|
266
269
|
}
|
|
267
270
|
|
|
@@ -272,10 +275,11 @@ function writeThemeTemplate(answers: InitAnswers): void {
|
|
|
272
275
|
version: answers.pluginVersion,
|
|
273
276
|
private: true,
|
|
274
277
|
scripts: {
|
|
275
|
-
build: "openvcs
|
|
276
|
-
|
|
278
|
+
build: "openvcs build",
|
|
279
|
+
dist: "openvcs dist --plugin-dir . --out dist",
|
|
280
|
+
test: "openvcs dist --plugin-dir . --out dist --no-build --no-npm-deps",
|
|
277
281
|
},
|
|
278
|
-
|
|
282
|
+
dependencies: {
|
|
279
283
|
"@openvcs/sdk": `^${packageJson.version}`,
|
|
280
284
|
},
|
|
281
285
|
});
|
|
@@ -361,4 +365,5 @@ export const __private = {
|
|
|
361
365
|
defaultPluginIdFromDir,
|
|
362
366
|
sanitizeIdToken,
|
|
363
367
|
validatePluginId,
|
|
368
|
+
writeModuleTemplate,
|
|
364
369
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Copyright © 2025-2026 OpenVCS Contributors
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import type { PluginHost, PluginImplements, PluginDelegates, JsonRpcId, JsonRpcRequest, VcsDelegates } from '../types';
|
|
5
|
+
|
|
6
|
+
/** Describes the transport endpoints used by the plugin runtime loop. */
|
|
7
|
+
export interface PluginRuntimeTransport {
|
|
8
|
+
/** Stores the readable stdin-like stream receiving framed messages. */
|
|
9
|
+
stdin: NodeJS.ReadStream;
|
|
10
|
+
/** Stores the writable stdout-like stream sending framed messages. */
|
|
11
|
+
stdout: NodeJS.WritableStream;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Describes the context object passed to every SDK delegate handler. */
|
|
15
|
+
export interface PluginRuntimeContext {
|
|
16
|
+
/** Stores the active host notification helper. */
|
|
17
|
+
host: PluginHost;
|
|
18
|
+
/** Stores the request id currently being processed. */
|
|
19
|
+
requestId: JsonRpcId;
|
|
20
|
+
/** Stores the current host method name. */
|
|
21
|
+
method: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Describes the options accepted by `createPluginRuntime`. */
|
|
25
|
+
export interface CreatePluginRuntimeOptions {
|
|
26
|
+
/** Stores optional plugin lifecycle and settings delegates. */
|
|
27
|
+
plugin?: PluginDelegates<PluginRuntimeContext>;
|
|
28
|
+
/** Stores optional VCS method delegates. */
|
|
29
|
+
vcs?: VcsDelegates<PluginRuntimeContext>;
|
|
30
|
+
/** Stores optional capability overrides for `plugin.initialize`. */
|
|
31
|
+
implements?: Partial<PluginImplements>;
|
|
32
|
+
/** Stores the `host.log` target emitted by the runtime. */
|
|
33
|
+
logTarget?: string;
|
|
34
|
+
/** Stores the timeout in milliseconds for request handlers. */
|
|
35
|
+
timeout?: number;
|
|
36
|
+
/** Called when runtime starts and begins processing requests. */
|
|
37
|
+
onStart?: () => void | Promise<void>;
|
|
38
|
+
/** Called during stop() after pending operations complete. Called with error if shutdown due to processing error. */
|
|
39
|
+
onShutdown?: (error?: Error) => void | Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Describes one created SDK plugin runtime instance. */
|
|
43
|
+
export interface PluginRuntime {
|
|
44
|
+
/** Starts listening on stdio for framed JSON-RPC requests. */
|
|
45
|
+
start(transport?: PluginRuntimeTransport): void;
|
|
46
|
+
/** Stops the runtime and cleans up pending operations. */
|
|
47
|
+
stop(): void;
|
|
48
|
+
/** Consumes one raw stdio chunk and dispatches complete requests. */
|
|
49
|
+
consumeChunk(chunk: Buffer | string): void;
|
|
50
|
+
/** Dispatches one already-decoded JSON-RPC request. */
|
|
51
|
+
dispatchRequest(request: JsonRpcRequest): Promise<void>;
|
|
52
|
+
}
|