@openvcs/sdk 0.4.0 → 0.4.1-edge.20260530.101

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 CHANGED
@@ -5,6 +5,8 @@
5
5
 
6
6
  OpenVCS SDK for npm-based plugin development.
7
7
 
8
+ Requires Node.js 20 or newer.
9
+
8
10
  Install this package in plugin projects, scaffold a starter plugin, and build
9
11
  plugin runtime assets. The SDK also exports a Node-only JSON-RPC runtime
10
12
  layer and shared protocol/types so plugins do not have to hand-roll stdio
@@ -41,6 +43,16 @@ Run tests (builds first):
41
43
  npm test
42
44
  ```
43
45
 
46
+ Generate a coverage report:
47
+
48
+ ```bash
49
+ npm run coverage
50
+ ```
51
+
52
+ This runs the full test flow, writes c8 output to `coverage/`, and fails if
53
+ coverage drops below 95% lines, 95% functions, 85% branches, or 95%
54
+ statements.
55
+
44
56
  Run the local CLI through npm scripts:
45
57
 
46
58
  ```bash
package/lib/build.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { npmExecutable } from "./npm-runner";
1
2
  /** CLI arguments for `openvcs build`. */
2
3
  export interface BuildArgs {
3
4
  pluginDir: string;
@@ -10,10 +11,6 @@ export interface ManifestInfo {
10
11
  entry: string | undefined;
11
12
  manifestPath: string;
12
13
  }
13
- /** Returns the npm executable name for the current platform. */
14
- export declare function npmExecutable(): string;
15
- /** Returns whether a command must be launched via the Windows shell. */
16
- export declare function shouldUseWindowsShell(program: string): boolean;
17
14
  /** Formats help text for the build command. */
18
15
  export declare function buildUsage(commandName?: string): string;
19
16
  /** Parses `openvcs build` arguments. */
package/lib/build.js CHANGED
@@ -35,8 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  };
36
36
  })();
37
37
  Object.defineProperty(exports, "__esModule", { value: true });
38
- exports.npmExecutable = npmExecutable;
39
- exports.shouldUseWindowsShell = shouldUseWindowsShell;
38
+ exports.npmExecutable = void 0;
40
39
  exports.buildUsage = buildUsage;
41
40
  exports.parseBuildArgs = parseBuildArgs;
42
41
  exports.readManifest = readManifest;
@@ -52,19 +51,10 @@ const fs = __importStar(require("node:fs"));
52
51
  const path = __importStar(require("node:path"));
53
52
  const node_child_process_1 = require("node:child_process");
54
53
  const fs_utils_1 = require("./fs-utils");
54
+ const npm_runner_1 = require("./npm-runner");
55
+ var npm_runner_2 = require("./npm-runner");
56
+ Object.defineProperty(exports, "npmExecutable", { enumerable: true, get: function () { return npm_runner_2.npmExecutable; } });
55
57
  const AUTHORED_PLUGIN_MODULE_BASENAME = "plugin.js";
56
- /** Returns the npm executable name for the current platform. */
57
- function npmExecutable() {
58
- return "npm";
59
- }
60
- /** Returns whether a command must be launched via the Windows shell. */
61
- function shouldUseWindowsShell(program) {
62
- if (process.platform !== "win32") {
63
- return false;
64
- }
65
- const normalized = program.toLowerCase();
66
- return normalized === "npm" || normalized.endsWith(".cmd") || normalized.endsWith(".bat");
67
- }
68
58
  /** Formats help text for the build command. */
69
59
  function buildUsage(commandName = "openvcs") {
70
60
  return `${commandName} build [args]\n\n --plugin-dir <path> Plugin repository root (contains package.json with openvcs metadata)\n -V, --verbose Enable verbose output\n`;
@@ -262,8 +252,8 @@ function runCommand(program, args, cwd, verbose) {
262
252
  }
263
253
  const result = (0, node_child_process_1.spawnSync)(program, args, {
264
254
  cwd,
265
- shell: shouldUseWindowsShell(program),
266
255
  stdio: ["ignore", verbose ? "inherit" : "ignore", "inherit"],
256
+ windowsHide: true,
267
257
  });
268
258
  if (result.error) {
269
259
  throw new Error(`failed to spawn '${program}' in ${cwd}: ${result.error.message}`);
@@ -307,7 +297,7 @@ function buildPluginAssets(parsedArgs) {
307
297
  if (typeof buildScript !== "string" || buildScript.trim() === "") {
308
298
  throw new Error(`code plugins must define scripts[\"build:plugin\"] in ${path.join(parsedArgs.pluginDir, "package.json")}`);
309
299
  }
310
- runCommand(npmExecutable(), ["run", "build:plugin"], parsedArgs.pluginDir, parsedArgs.verbose);
300
+ runCommand((0, npm_runner_1.npmExecutable)(), [...(0, npm_runner_1.npmArgsPrefix)(), "run", "build:plugin"], parsedArgs.pluginDir, parsedArgs.verbose);
311
301
  generateModuleBootstrap(parsedArgs.pluginDir, manifest.moduleExec);
312
302
  validateDeclaredModuleExec(parsedArgs.pluginDir, manifest.moduleExec);
313
303
  return manifest;
package/lib/init.d.ts CHANGED
@@ -22,18 +22,22 @@ interface InitCommandError {
22
22
  export declare function initUsage(commandName?: string): string;
23
23
  declare function sanitizeIdToken(raw: string): string;
24
24
  declare function defaultPluginIdFromDir(targetDir: string): string;
25
+ declare function defaultPluginNameFromId(pluginId: string): string;
25
26
  declare function validatePluginId(pluginId: string): string | undefined;
26
27
  declare function createReadlinePromptDriver(output?: NodeJS.WritableStream): PromptDriver;
27
28
  declare function collectAnswers({ forceTheme, targetHint }: CollectAnswersOptions, promptDriver?: PromptDriver, output?: NodeJS.WritableStream): Promise<InitAnswers>;
28
29
  declare function writeModuleTemplate(answers: InitAnswers): void;
30
+ declare function writeThemeTemplate(answers: InitAnswers): void;
29
31
  export declare function runInitCommand(args: string[]): Promise<string>;
30
32
  export declare function isUsageError(error: unknown): error is InitCommandError;
31
33
  export declare const __private: {
32
34
  collectAnswers: typeof collectAnswers;
33
35
  createReadlinePromptDriver: typeof createReadlinePromptDriver;
36
+ defaultPluginNameFromId: typeof defaultPluginNameFromId;
34
37
  defaultPluginIdFromDir: typeof defaultPluginIdFromDir;
35
38
  sanitizeIdToken: typeof sanitizeIdToken;
36
39
  validatePluginId: typeof validatePluginId;
37
40
  writeModuleTemplate: typeof writeModuleTemplate;
41
+ writeThemeTemplate: typeof writeThemeTemplate;
38
42
  };
39
43
  export {};
package/lib/init.js CHANGED
@@ -42,17 +42,8 @@ const path = __importStar(require("node:path"));
42
42
  const readline = __importStar(require("node:readline/promises"));
43
43
  const node_process_1 = require("node:process");
44
44
  const node_child_process_1 = require("node:child_process");
45
+ const npm_runner_1 = require("./npm-runner");
45
46
  const packageJson = require("../package.json");
46
- function npmExecutable() {
47
- return "npm";
48
- }
49
- function shouldUseWindowsShell(program) {
50
- if (process.platform !== "win32") {
51
- return false;
52
- }
53
- const normalized = program.toLowerCase();
54
- return normalized === "npm" || normalized.endsWith(".cmd") || normalized.endsWith(".bat");
55
- }
56
47
  function initUsage(commandName = "openvcs") {
57
48
  return `Usage: ${commandName} init [--theme] [target-dir]\n\nOptions:\n --theme Start with a theme-only plugin template\n`;
58
49
  }
@@ -203,10 +194,10 @@ async function collectAnswers({ forceTheme, targetHint }, promptDriver = createR
203
194
  }
204
195
  }
205
196
  function runNpmInstall(targetDir) {
206
- const result = (0, node_child_process_1.spawnSync)(npmExecutable(), ["install"], {
197
+ const result = (0, node_child_process_1.spawnSync)((0, npm_runner_1.npmExecutable)(), [...(0, npm_runner_1.npmArgsPrefix)(), "install"], {
207
198
  cwd: targetDir,
208
- shell: shouldUseWindowsShell(npmExecutable()),
209
199
  stdio: "inherit",
200
+ windowsHide: true,
210
201
  });
211
202
  if (result.error) {
212
203
  throw new Error(`failed to spawn npm install in ${targetDir}: ${result.error.message}`);
@@ -228,7 +219,6 @@ function writeModuleTemplate(answers) {
228
219
  openvcs: {
229
220
  id: answers.pluginId,
230
221
  name: answers.pluginName,
231
- version: answers.pluginVersion,
232
222
  default_enabled: answers.defaultEnabled,
233
223
  module: { exec: "openvcs-plugin.js" },
234
224
  },
@@ -269,7 +259,6 @@ function writeThemeTemplate(answers) {
269
259
  openvcs: {
270
260
  id: answers.pluginId,
271
261
  name: answers.pluginName,
272
- version: answers.pluginVersion,
273
262
  default_enabled: answers.defaultEnabled,
274
263
  },
275
264
  scripts: {
@@ -350,8 +339,10 @@ function isUsageError(error) {
350
339
  exports.__private = {
351
340
  collectAnswers,
352
341
  createReadlinePromptDriver,
342
+ defaultPluginNameFromId,
353
343
  defaultPluginIdFromDir,
354
344
  sanitizeIdToken,
355
345
  validatePluginId,
356
346
  writeModuleTemplate,
347
+ writeThemeTemplate,
357
348
  };
@@ -0,0 +1,11 @@
1
+ type ExistsFn = (path: string) => boolean;
2
+ type ResolveFn = (specifier: string) => string;
3
+ export interface NpmCommand {
4
+ program: string;
5
+ argsPrefix: string[];
6
+ }
7
+ export declare function resolveNpmCli(execPath?: string, exists?: ExistsFn, resolve?: ResolveFn): string;
8
+ export declare function npmCommand(platform?: NodeJS.Platform, execPath?: string, exists?: ExistsFn, resolve?: ResolveFn): NpmCommand;
9
+ export declare function npmExecutable(): string;
10
+ export declare function npmArgsPrefix(): string[];
11
+ export {};
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ // Copyright © 2025-2026 OpenVCS Contributors
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
5
+ if (k2 === undefined) k2 = k;
6
+ var desc = Object.getOwnPropertyDescriptor(m, k);
7
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
8
+ desc = { enumerable: true, get: function() { return m[k]; } };
9
+ }
10
+ Object.defineProperty(o, k2, desc);
11
+ }) : (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ o[k2] = m[k];
14
+ }));
15
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
16
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
17
+ }) : function(o, v) {
18
+ o["default"] = v;
19
+ });
20
+ var __importStar = (this && this.__importStar) || (function () {
21
+ var ownKeys = function(o) {
22
+ ownKeys = Object.getOwnPropertyNames || function (o) {
23
+ var ar = [];
24
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
25
+ return ar;
26
+ };
27
+ return ownKeys(o);
28
+ };
29
+ return function (mod) {
30
+ if (mod && mod.__esModule) return mod;
31
+ var result = {};
32
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
33
+ __setModuleDefault(result, mod);
34
+ return result;
35
+ };
36
+ })();
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.resolveNpmCli = resolveNpmCli;
39
+ exports.npmCommand = npmCommand;
40
+ exports.npmExecutable = npmExecutable;
41
+ exports.npmArgsPrefix = npmArgsPrefix;
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ function resolveNpmCli(execPath = process.execPath, exists = fs.existsSync, resolve = require.resolve) {
45
+ const localNodeModules = path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js");
46
+ if (exists(localNodeModules)) {
47
+ return localNodeModules;
48
+ }
49
+ return resolve("npm/bin/npm-cli.js");
50
+ }
51
+ function npmCommand(platform = process.platform, execPath = process.execPath, exists = fs.existsSync, resolve = require.resolve) {
52
+ if (platform !== "win32") {
53
+ return { program: "npm", argsPrefix: [] };
54
+ }
55
+ return { program: execPath, argsPrefix: [resolveNpmCli(execPath, exists, resolve)] };
56
+ }
57
+ function npmExecutable() {
58
+ return npmCommand().program;
59
+ }
60
+ function npmArgsPrefix() {
61
+ return npmCommand().argsPrefix;
62
+ }
@@ -83,23 +83,31 @@ function createRuntimeDispatcher(options, host, writer) {
83
83
  }
84
84
  let result;
85
85
  if (timeout && timeout > 0) {
86
- const controller = new AbortController();
87
- const timeoutId = setTimeout(() => controller.abort(), timeout);
86
+ let timeoutId;
87
+ const timeoutPromise = new Promise((_resolve, reject) => {
88
+ timeoutId = setTimeout(() => {
89
+ reject((0, errors_1.pluginError)('request-timeout', `method '${method}' timed out after ${timeout}ms`));
90
+ }, timeout);
91
+ });
88
92
  try {
89
- result = await handler(params, {
90
- host,
91
- requestId: id,
92
- method,
93
- });
93
+ result = await Promise.race([
94
+ handler(params, {
95
+ host,
96
+ requestId: id,
97
+ method,
98
+ }),
99
+ timeoutPromise,
100
+ ]);
94
101
  }
95
102
  catch (error) {
96
- clearTimeout(timeoutId);
97
- if (error.name === 'AbortError') {
98
- throw (0, errors_1.pluginError)('request-timeout', `method '${method}' timed out after ${timeout}ms`);
103
+ if (timeoutId !== undefined) {
104
+ clearTimeout(timeoutId);
99
105
  }
100
106
  throw error;
101
107
  }
102
- clearTimeout(timeoutId);
108
+ if (timeoutId !== undefined) {
109
+ clearTimeout(timeoutId);
110
+ }
103
111
  }
104
112
  else {
105
113
  result = await handler(params, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.4.1-edge.20260530.101",
4
4
  "description": "OpenVCS SDK CLI for plugin scaffolding and runtime asset builds",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "homepage": "https://openvcs.app/",
@@ -12,7 +12,7 @@
12
12
  "url": "https://github.com/Open-VCS/OpenVCS-SDK/issues"
13
13
  },
14
14
  "engines": {
15
- "node": ">=18"
15
+ "node": ">=20"
16
16
  },
17
17
  "bin": {
18
18
  "openvcs": "bin/openvcs.js"
@@ -32,7 +32,9 @@
32
32
  },
33
33
  "scripts": {
34
34
  "build": "node scripts/build-sdk.js",
35
- "test": "npm run build && npm run test:types && node --test test/*.test.js",
35
+ "test": "npm run build && npm run test:types && node scripts/run-tests.js",
36
+ "coverage": "npm run build && npm run test:types && c8 --check-coverage --lines 95 --functions 95 --branches 85 --statements 95 --reporter=text --reporter=lcov node scripts/run-tests.js",
37
+ "test:coverage": "npm run coverage",
36
38
  "test:types": "node ./node_modules/typescript/bin/tsc -p test/tsconfig.json",
37
39
  "prepack": "npm run build",
38
40
  "preopenvcs": "npm run build",
@@ -40,6 +42,7 @@
40
42
  },
41
43
  "devDependencies": {
42
44
  "@types/node": "^25.3.3",
45
+ "c8": "^11.0.0",
43
46
  "typescript": "^6.0.3"
44
47
  },
45
48
  "files": [
package/src/lib/build.ts CHANGED
@@ -6,6 +6,9 @@ import * as path from "node:path";
6
6
  import { spawnSync } from "node:child_process";
7
7
 
8
8
  import { isPathInside } from "./fs-utils";
9
+ import { npmArgsPrefix, npmExecutable } from "./npm-runner";
10
+
11
+ export { npmExecutable } from "./npm-runner";
9
12
 
10
13
  type UsageError = Error & { code?: string };
11
14
 
@@ -34,21 +37,6 @@ interface PackageScripts {
34
37
 
35
38
  const AUTHORED_PLUGIN_MODULE_BASENAME = "plugin.js";
36
39
 
37
- /** Returns the npm executable name for the current platform. */
38
- export function npmExecutable(): string {
39
- return "npm";
40
- }
41
-
42
- /** Returns whether a command must be launched via the Windows shell. */
43
- export function shouldUseWindowsShell(program: string): boolean {
44
- if (process.platform !== "win32") {
45
- return false;
46
- }
47
-
48
- const normalized = program.toLowerCase();
49
- return normalized === "npm" || normalized.endsWith(".cmd") || normalized.endsWith(".bat");
50
- }
51
-
52
40
  /** Formats help text for the build command. */
53
41
  export function buildUsage(commandName = "openvcs"): string {
54
42
  return `${commandName} build [args]\n\n --plugin-dir <path> Plugin repository root (contains package.json with openvcs metadata)\n -V, --verbose Enable verbose output\n`;
@@ -288,8 +276,8 @@ export function runCommand(program: string, args: string[], cwd: string, verbose
288
276
 
289
277
  const result = spawnSync(program, args, {
290
278
  cwd,
291
- shell: shouldUseWindowsShell(program),
292
279
  stdio: ["ignore", verbose ? "inherit" : "ignore", "inherit"],
280
+ windowsHide: true,
293
281
  }) as CommandResult;
294
282
 
295
283
  if (result.error) {
@@ -342,7 +330,7 @@ export function buildPluginAssets(parsedArgs: BuildArgs): ManifestInfo {
342
330
  );
343
331
  }
344
332
 
345
- runCommand(npmExecutable(), ["run", "build:plugin"], parsedArgs.pluginDir, parsedArgs.verbose);
333
+ runCommand(npmExecutable(), [...npmArgsPrefix(), "run", "build:plugin"], parsedArgs.pluginDir, parsedArgs.verbose);
346
334
  generateModuleBootstrap(parsedArgs.pluginDir, manifest.moduleExec);
347
335
  validateDeclaredModuleExec(parsedArgs.pluginDir, manifest.moduleExec);
348
336
  return manifest;
package/src/lib/init.ts CHANGED
@@ -4,6 +4,8 @@ import * as readline from "node:readline/promises";
4
4
  import { stdin, stdout } from "node:process";
5
5
  import { spawnSync } from "node:child_process";
6
6
 
7
+ import { npmArgsPrefix, npmExecutable } from "./npm-runner";
8
+
7
9
  const packageJson: { version: string } = require("../package.json");
8
10
 
9
11
  type UsageError = Error & { code?: string };
@@ -33,19 +35,6 @@ interface InitCommandError {
33
35
  code?: string;
34
36
  }
35
37
 
36
- function npmExecutable(): string {
37
- return "npm";
38
- }
39
-
40
- function shouldUseWindowsShell(program: string): boolean {
41
- if (process.platform !== "win32") {
42
- return false;
43
- }
44
-
45
- const normalized = program.toLowerCase();
46
- return normalized === "npm" || normalized.endsWith(".cmd") || normalized.endsWith(".bat");
47
- }
48
-
49
38
  export function initUsage(commandName = "openvcs"): string {
50
39
  return `Usage: ${commandName} init [--theme] [target-dir]\n\nOptions:\n --theme Start with a theme-only plugin template\n`;
51
40
  }
@@ -214,10 +203,10 @@ async function collectAnswers(
214
203
  }
215
204
 
216
205
  function runNpmInstall(targetDir: string): void {
217
- const result = spawnSync(npmExecutable(), ["install"], {
206
+ const result = spawnSync(npmExecutable(), [...npmArgsPrefix(), "install"], {
218
207
  cwd: targetDir,
219
- shell: shouldUseWindowsShell(npmExecutable()),
220
208
  stdio: "inherit",
209
+ windowsHide: true,
221
210
  });
222
211
  if (result.error) {
223
212
  throw new Error(`failed to spawn npm install in ${targetDir}: ${result.error.message}`);
@@ -241,7 +230,6 @@ function writeModuleTemplate(answers: InitAnswers): void {
241
230
  openvcs: {
242
231
  id: answers.pluginId,
243
232
  name: answers.pluginName,
244
- version: answers.pluginVersion,
245
233
  default_enabled: answers.defaultEnabled,
246
234
  module: { exec: "openvcs-plugin.js" },
247
235
  },
@@ -286,7 +274,6 @@ function writeThemeTemplate(answers: InitAnswers): void {
286
274
  openvcs: {
287
275
  id: answers.pluginId,
288
276
  name: answers.pluginName,
289
- version: answers.pluginVersion,
290
277
  default_enabled: answers.defaultEnabled,
291
278
  },
292
279
  scripts: {
@@ -376,8 +363,10 @@ export function isUsageError(error: unknown): error is InitCommandError {
376
363
  export const __private = {
377
364
  collectAnswers,
378
365
  createReadlinePromptDriver,
366
+ defaultPluginNameFromId,
379
367
  defaultPluginIdFromDir,
380
368
  sanitizeIdToken,
381
369
  validatePluginId,
382
370
  writeModuleTemplate,
371
+ writeThemeTemplate,
383
372
  };
@@ -0,0 +1,47 @@
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
+
7
+ type ExistsFn = (path: string) => boolean;
8
+ type ResolveFn = (specifier: string) => string;
9
+
10
+ export interface NpmCommand {
11
+ program: string;
12
+ argsPrefix: string[];
13
+ }
14
+
15
+ export function resolveNpmCli(
16
+ execPath = process.execPath,
17
+ exists: ExistsFn = fs.existsSync,
18
+ resolve: ResolveFn = require.resolve,
19
+ ): string {
20
+ const localNodeModules = path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js");
21
+ if (exists(localNodeModules)) {
22
+ return localNodeModules;
23
+ }
24
+
25
+ return resolve("npm/bin/npm-cli.js");
26
+ }
27
+
28
+ export function npmCommand(
29
+ platform = process.platform,
30
+ execPath = process.execPath,
31
+ exists: ExistsFn = fs.existsSync,
32
+ resolve: ResolveFn = require.resolve,
33
+ ): NpmCommand {
34
+ if (platform !== "win32") {
35
+ return { program: "npm", argsPrefix: [] };
36
+ }
37
+
38
+ return { program: execPath, argsPrefix: [resolveNpmCli(execPath, exists, resolve)] };
39
+ }
40
+
41
+ export function npmExecutable(): string {
42
+ return npmCommand().program;
43
+ }
44
+
45
+ export function npmArgsPrefix(): string[] {
46
+ return npmCommand().argsPrefix;
47
+ }
@@ -133,22 +133,30 @@ export function createRuntimeDispatcher(
133
133
 
134
134
  let result: unknown;
135
135
  if (timeout && timeout > 0) {
136
- const controller = new AbortController();
137
- const timeoutId = setTimeout(() => controller.abort(), timeout);
136
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
137
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
138
+ timeoutId = setTimeout(() => {
139
+ reject(pluginError('request-timeout', `method '${method}' timed out after ${timeout}ms`));
140
+ }, timeout);
141
+ });
138
142
  try {
139
- result = await handler(params, {
140
- host,
141
- requestId: id,
142
- method,
143
- });
143
+ result = await Promise.race([
144
+ handler(params, {
145
+ host,
146
+ requestId: id,
147
+ method,
148
+ }),
149
+ timeoutPromise,
150
+ ]);
144
151
  } catch (error) {
145
- clearTimeout(timeoutId);
146
- if ((error as Error).name === 'AbortError') {
147
- throw pluginError('request-timeout', `method '${method}' timed out after ${timeout}ms`);
152
+ if (timeoutId !== undefined) {
153
+ clearTimeout(timeoutId);
148
154
  }
149
155
  throw error;
150
156
  }
151
- clearTimeout(timeoutId);
157
+ if (timeoutId !== undefined) {
158
+ clearTimeout(timeoutId);
159
+ }
152
160
  } else {
153
161
  result = await handler(params, {
154
162
  host,
@@ -4,9 +4,13 @@ const path = require("node:path");
4
4
  const test = require("node:test");
5
5
 
6
6
  const {
7
+ authoredPluginModulePath,
7
8
  buildPluginAssets,
9
+ generateModuleBootstrap,
10
+ hasPackageJson,
8
11
  parseBuildArgs,
9
12
  readManifest,
13
+ runCommand,
10
14
  validateDeclaredModuleExec,
11
15
  validateGeneratedBootstrapTargets,
12
16
  } = require("../lib/build");
@@ -48,6 +52,10 @@ test("parseBuildArgs help returns usage error", () => {
48
52
  assert.throws(() => parseBuildArgs(["--help"]), /openvcs build \[args\]/);
49
53
  });
50
54
 
55
+ test("parseBuildArgs rejects unknown flags", () => {
56
+ assert.throws(() => parseBuildArgs(["--wat"]), /unknown flag: --wat/);
57
+ });
58
+
51
59
  test("buildPluginAssets no-ops for theme-only plugins", () => {
52
60
  const root = makeTempDir("openvcs-sdk-test");
53
61
  const pluginDir = path.join(root, "plugin");
@@ -136,6 +144,73 @@ test("readManifest and validateDeclaredModuleExec stay reusable", () => {
136
144
  cleanupTempDir(root);
137
145
  });
138
146
 
147
+ test("readManifest reports missing and invalid package manifests", () => {
148
+ const root = makeTempDir("openvcs-sdk-test");
149
+ const missingDir = path.join(root, "missing");
150
+ fs.mkdirSync(missingDir, { recursive: true });
151
+ assert.throws(() => readManifest(missingDir), /missing package\.json/);
152
+
153
+ const invalidDir = path.join(root, "invalid");
154
+ writeText(path.join(invalidDir, "package.json"), "{");
155
+ assert.throws(() => readManifest(invalidDir), /parse .*package\.json/);
156
+
157
+ const noOpenVcs = path.join(root, "no-openvcs");
158
+ writeJson(path.join(noOpenVcs, "package.json"), { name: "x" });
159
+ assert.throws(() => readManifest(noOpenVcs), /missing an 'openvcs' object/);
160
+
161
+ const badId = path.join(root, "bad-id");
162
+ writeJson(path.join(badId, "package.json"), { openvcs: { id: "bad/id" } });
163
+ assert.throws(() => readManifest(badId), /must not contain path separators/);
164
+
165
+ const missingId = path.join(root, "missing-id");
166
+ writeJson(path.join(missingId, "package.json"), { openvcs: {} });
167
+ assert.throws(() => readManifest(missingId), /missing openvcs\.id/);
168
+
169
+ cleanupTempDir(root);
170
+ });
171
+
172
+ test("validateDeclaredModuleExec rejects invalid module paths", () => {
173
+ const root = makeTempDir("openvcs-sdk-test");
174
+ const pluginDir = path.join(root, "plugin");
175
+ fs.mkdirSync(path.join(pluginDir, "bin"), { recursive: true });
176
+
177
+ assert.doesNotThrow(() => validateDeclaredModuleExec(pluginDir, undefined));
178
+ assert.throws(() => validateDeclaredModuleExec(pluginDir, "native.node"), /must end with/);
179
+ assert.throws(() => validateDeclaredModuleExec(pluginDir, path.join(pluginDir, "bin", "x.js")), /must be a relative path/);
180
+ assert.throws(() => validateDeclaredModuleExec(pluginDir, "../escape.js"), /must point to a file under bin/);
181
+ assert.throws(() => validateDeclaredModuleExec(pluginDir, "missing.js"), /module entrypoint not found/);
182
+
183
+ cleanupTempDir(root);
184
+ });
185
+
186
+ test("renderGeneratedBootstrap rejects unsafe import paths", () => {
187
+ assert.throws(() => require("../lib/build").renderGeneratedBootstrap("./bad path.js", true), /unsafe module import path/);
188
+ });
189
+
190
+ test("build helpers handle no-op and package existence paths", () => {
191
+ const root = makeTempDir("openvcs-sdk-test");
192
+ const pluginDir = path.join(root, "plugin");
193
+ fs.mkdirSync(pluginDir, { recursive: true });
194
+
195
+ assert.equal(hasPackageJson(pluginDir), false);
196
+ writeJson(path.join(pluginDir, "package.json"), { openvcs: { id: "x" } });
197
+ assert.equal(hasPackageJson(pluginDir), true);
198
+ assert.equal(authoredPluginModulePath(pluginDir), path.join(pluginDir, "bin", "plugin.js"));
199
+ assert.doesNotThrow(() => generateModuleBootstrap(pluginDir, undefined));
200
+
201
+ cleanupTempDir(root);
202
+ });
203
+
204
+ test("runCommand reports spawn failures and non-zero exits", () => {
205
+ const root = makeTempDir("openvcs-sdk-test");
206
+
207
+ assert.doesNotThrow(() => runCommand(process.execPath, ["-e", "process.exit(0)"], root, true));
208
+ assert.throws(() => runCommand(process.execPath, ["-e", "process.exit(7)"], root, false), /exit code 7/);
209
+ assert.throws(() => runCommand(path.join(root, "missing-binary"), [], root, false), /failed to spawn/);
210
+
211
+ cleanupTempDir(root);
212
+ });
213
+
139
214
  test("validateGeneratedBootstrapTargets rejects module.exec collisions", () => {
140
215
  const root = makeTempDir("openvcs-sdk-test");
141
216
  const pluginDir = path.join(root, "plugin");
@@ -168,6 +243,30 @@ test("validateGeneratedBootstrapTargets rejects case-insensitive collisions", ()
168
243
  cleanupTempDir(root);
169
244
  });
170
245
 
246
+ test("validateGeneratedBootstrapTargets no-ops without module exec and rejects missing compiled module", () => {
247
+ const root = makeTempDir("openvcs-sdk-test");
248
+ const pluginDir = path.join(root, "plugin");
249
+ fs.mkdirSync(path.join(pluginDir, "bin"), { recursive: true });
250
+
251
+ assert.doesNotThrow(() => validateGeneratedBootstrapTargets(pluginDir, undefined));
252
+ assert.throws(() => validateGeneratedBootstrapTargets(pluginDir, "openvcs-plugin.js"), /compiled plugin module not found/);
253
+
254
+ cleanupTempDir(root);
255
+ });
256
+
257
+ test("generateModuleBootstrap tolerates invalid package json and uses extension for ESM", () => {
258
+ const root = makeTempDir("openvcs-sdk-test");
259
+ const pluginDir = path.join(root, "plugin");
260
+ writeText(path.join(pluginDir, "package.json"), "{");
261
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
262
+ writeText(path.join(pluginDir, "bin", "bootstrap.mjs"), "");
263
+
264
+ generateModuleBootstrap(pluginDir, "bootstrap.mjs");
265
+
266
+ assert.match(fs.readFileSync(path.join(pluginDir, "bin", "bootstrap.mjs"), "utf8"), /^import/m);
267
+ cleanupTempDir(root);
268
+ });
269
+
171
270
  test("generateModuleBootstrap handles subdirectory module.exec paths", () => {
172
271
  const root = makeTempDir("openvcs-sdk-test");
173
272
  const pluginDir = path.join(root, "plugin");