@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.
Files changed (51) hide show
  1. package/README.md +76 -7
  2. package/lib/build.d.ts +28 -0
  3. package/lib/build.js +188 -0
  4. package/lib/cli.js +21 -2
  5. package/lib/dist.d.ts +4 -7
  6. package/lib/dist.js +67 -113
  7. package/lib/init.d.ts +2 -0
  8. package/lib/init.js +13 -8
  9. package/lib/runtime/contracts.d.ts +45 -0
  10. package/lib/runtime/contracts.js +4 -0
  11. package/lib/runtime/dispatcher.d.ts +16 -0
  12. package/lib/runtime/dispatcher.js +133 -0
  13. package/lib/runtime/errors.d.ts +5 -0
  14. package/lib/runtime/errors.js +26 -0
  15. package/lib/runtime/host.d.ts +10 -0
  16. package/lib/runtime/host.js +48 -0
  17. package/lib/runtime/index.d.ts +9 -0
  18. package/lib/runtime/index.js +166 -0
  19. package/lib/runtime/transport.d.ts +14 -0
  20. package/lib/runtime/transport.js +72 -0
  21. package/lib/types/host.d.ts +57 -0
  22. package/lib/types/host.js +4 -0
  23. package/lib/types/index.d.ts +4 -0
  24. package/lib/types/index.js +22 -0
  25. package/lib/types/plugin.d.ts +56 -0
  26. package/lib/types/plugin.js +4 -0
  27. package/lib/types/protocol.d.ts +77 -0
  28. package/lib/types/protocol.js +13 -0
  29. package/lib/types/vcs.d.ts +459 -0
  30. package/lib/types/vcs.js +4 -0
  31. package/package.json +16 -3
  32. package/src/lib/build.ts +229 -0
  33. package/src/lib/cli.ts +21 -2
  34. package/src/lib/dist.ts +76 -128
  35. package/src/lib/init.ts +13 -8
  36. package/src/lib/runtime/contracts.ts +52 -0
  37. package/src/lib/runtime/dispatcher.ts +185 -0
  38. package/src/lib/runtime/errors.ts +27 -0
  39. package/src/lib/runtime/host.ts +72 -0
  40. package/src/lib/runtime/index.ts +201 -0
  41. package/src/lib/runtime/transport.ts +93 -0
  42. package/src/lib/types/host.ts +71 -0
  43. package/src/lib/types/index.ts +7 -0
  44. package/src/lib/types/plugin.ts +110 -0
  45. package/src/lib/types/protocol.ts +97 -0
  46. package/src/lib/types/vcs.ts +579 -0
  47. package/test/build.test.js +95 -0
  48. package/test/cli.test.js +37 -0
  49. package/test/dist.test.js +239 -15
  50. package/test/init.test.js +25 -0
  51. package/test/runtime.test.js +118 -0
@@ -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.includes("-v") || args.includes("--version")) {
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 readManifest(pluginDir: string): ManifestInfo {
92
- const manifestPath = path.join(pluginDir, "openvcs.plugin.json");
93
- let manifestRaw: string;
94
- let manifestFd: number | undefined;
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
- try {
115
- manifest = JSON.parse(manifestRaw);
116
- } catch (error: unknown) {
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(`module entrypoint not found at ${targetPath}`);
102
+ throw new Error(`manifest entry file not found: ${entry}`);
163
103
  }
164
104
  }
165
105
 
166
- function hasPackageJson(pluginDir: string): boolean {
167
- const packageJsonPath = path.join(pluginDir, "package.json");
168
- return fs.existsSync(packageJsonPath) && fs.lstatSync(packageJsonPath).isFile();
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 runCommand(program: string, args: string[], cwd: string, verbose: boolean): void {
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 ${pluginDir}\n`);
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
- pluginDir,
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
- copyFileStrict(lockPath, path.join(bundleDir, "package-lock.json"));
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
- for (const extension of ICON_EXTENSIONS) {
268
- const fileName = `icon.${extension}`;
269
- const sourcePath = path.join(pluginDir, fileName);
270
- if (!fs.existsSync(sourcePath)) {
271
- continue;
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 } = readManifest(pluginDir);
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 (!moduleExec && !hasThemes) {
309
- throw new Error("manifest has no module.exec or themes/");
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:ts": "tsc -p tsconfig.json",
240
- build: "npm run build:ts && openvcs dist --plugin-dir . --out dist",
241
- test: "openvcs dist --plugin-dir . --out dist --no-npm-deps",
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
- devDependencies: {
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
- "const message = \"OpenVCS plugin started\";\nprocess.stderr.write(`${message}\\n`);\n"
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 dist --plugin-dir . --out dist",
276
- test: "openvcs dist --plugin-dir . --out dist --no-npm-deps",
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
- devDependencies: {
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
+ }