@openvcs/sdk 0.4.0 → 0.4.1-beta.128

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.
@@ -4,14 +4,32 @@ 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");
13
17
  const { cleanupTempDir, makeTempDir, writeJson, writeText } = require("./helpers");
14
18
 
19
+ function captureStderr(fn) {
20
+ const chunks = [];
21
+ const originalWrite = process.stderr.write;
22
+ process.stderr.write = (chunk) => {
23
+ chunks.push(String(chunk));
24
+ return true;
25
+ };
26
+ try {
27
+ return { result: fn(), stderr: chunks.join("") };
28
+ } finally {
29
+ process.stderr.write = originalWrite;
30
+ }
31
+ }
32
+
15
33
  test("renderGeneratedBootstrap creates ESM code", () => {
16
34
  const output = require("../lib/build").renderGeneratedBootstrap("./plugin.js", true);
17
35
  assert.match(output, /^#!/);
@@ -48,6 +66,10 @@ test("parseBuildArgs help returns usage error", () => {
48
66
  assert.throws(() => parseBuildArgs(["--help"]), /openvcs build \[args\]/);
49
67
  });
50
68
 
69
+ test("parseBuildArgs rejects unknown flags", () => {
70
+ assert.throws(() => parseBuildArgs(["--wat"]), /unknown flag: --wat/);
71
+ });
72
+
51
73
  test("buildPluginAssets no-ops for theme-only plugins", () => {
52
74
  const root = makeTempDir("openvcs-sdk-test");
53
75
  const pluginDir = path.join(root, "plugin");
@@ -56,9 +78,11 @@ test("buildPluginAssets no-ops for theme-only plugins", () => {
56
78
  name: "theme-only",
57
79
  openvcs: { id: "theme-only" },
58
80
  });
59
- const manifest = buildPluginAssets({ pluginDir, verbose: false });
81
+ const { result: manifest, stderr } = captureStderr(() => buildPluginAssets({ pluginDir, verbose: false }));
60
82
 
61
83
  assert.equal(manifest.pluginId, "theme-only");
84
+ assert.match(stderr, /openvcs build: reading plugin manifest/);
85
+ assert.match(stderr, /openvcs build: theme-only plugin theme-only; nothing to build/);
62
86
  cleanupTempDir(root);
63
87
  });
64
88
 
@@ -102,7 +126,7 @@ test("buildPluginAssets runs build:plugin and validates output", () => {
102
126
  "const fs = require('node:fs');\nconst path = require('node:path');\nconst out = path.join(process.cwd(), 'bin', 'plugin.js');\nfs.mkdirSync(path.dirname(out), { recursive: true });\nfs.writeFileSync(out, 'export {};\\n', 'utf8');\n"
103
127
  );
104
128
 
105
- const manifest = buildPluginAssets({ pluginDir, verbose: false });
129
+ const { result: manifest, stderr } = captureStderr(() => buildPluginAssets({ pluginDir, verbose: false }));
106
130
 
107
131
  assert.equal(manifest.pluginId, "builder");
108
132
  assert.equal(fs.existsSync(path.join(pluginDir, "bin", "plugin.js")), true);
@@ -111,6 +135,42 @@ test("buildPluginAssets runs build:plugin and validates output", () => {
111
135
  fs.readFileSync(path.join(pluginDir, "bin", "openvcs-plugin.js"), "utf8"),
112
136
  /bootstrapPluginModule/
113
137
  );
138
+ assert.match(stderr, /openvcs build: reading plugin manifest/);
139
+ assert.match(stderr, /openvcs build: running build:plugin/);
140
+ assert.match(stderr, /openvcs build: generating bootstrap openvcs-plugin.js/);
141
+ assert.match(stderr, /openvcs build: validating bootstrap openvcs-plugin.js/);
142
+ assert.match(stderr, /openvcs build: build complete for builder/);
143
+ cleanupTempDir(root);
144
+ });
145
+
146
+ test("buildPluginAssets verbose mode adds detailed progress output", () => {
147
+ const root = makeTempDir("openvcs-sdk-test");
148
+ const pluginDir = path.join(root, "plugin");
149
+
150
+ writeJson(path.join(pluginDir, "package.json"), {
151
+ name: "verbose-builder",
152
+ private: true,
153
+ type: "module",
154
+ openvcs: {
155
+ id: "verbose-builder",
156
+ module: { exec: "openvcs-plugin.mjs" },
157
+ },
158
+ scripts: {
159
+ "build:plugin": "node ./scripts/build-plugin.cjs",
160
+ },
161
+ });
162
+ writeText(
163
+ path.join(pluginDir, "scripts", "build-plugin.cjs"),
164
+ "const fs = require('node:fs');\nconst path = require('node:path');\nconst out = path.join(process.cwd(), 'bin', 'plugin.js');\nfs.mkdirSync(path.dirname(out), { recursive: true });\nfs.writeFileSync(out, 'export {};\\n', 'utf8');\n"
165
+ );
166
+
167
+ const { result: manifest, stderr } = captureStderr(() => buildPluginAssets({ pluginDir, verbose: true }));
168
+
169
+ assert.equal(manifest.pluginId, "verbose-builder");
170
+ assert.match(stderr, /openvcs build: reading manifest at .*package\.json/);
171
+ assert.match(stderr, /openvcs build: manifest loaded for verbose-builder \(module\.exec: openvcs-plugin\.mjs\)/);
172
+ assert.match(stderr, /Running command in .*: .* run build:plugin/);
173
+ assert.match(stderr, /openvcs build: writing bootstrap .*openvcs-plugin\.mjs -> .*plugin\.js \(esm\)/);
114
174
  cleanupTempDir(root);
115
175
  });
116
176
 
@@ -136,6 +196,73 @@ test("readManifest and validateDeclaredModuleExec stay reusable", () => {
136
196
  cleanupTempDir(root);
137
197
  });
138
198
 
199
+ test("readManifest reports missing and invalid package manifests", () => {
200
+ const root = makeTempDir("openvcs-sdk-test");
201
+ const missingDir = path.join(root, "missing");
202
+ fs.mkdirSync(missingDir, { recursive: true });
203
+ assert.throws(() => readManifest(missingDir), /missing package\.json/);
204
+
205
+ const invalidDir = path.join(root, "invalid");
206
+ writeText(path.join(invalidDir, "package.json"), "{");
207
+ assert.throws(() => readManifest(invalidDir), /parse .*package\.json/);
208
+
209
+ const noOpenVcs = path.join(root, "no-openvcs");
210
+ writeJson(path.join(noOpenVcs, "package.json"), { name: "x" });
211
+ assert.throws(() => readManifest(noOpenVcs), /missing an 'openvcs' object/);
212
+
213
+ const badId = path.join(root, "bad-id");
214
+ writeJson(path.join(badId, "package.json"), { openvcs: { id: "bad/id" } });
215
+ assert.throws(() => readManifest(badId), /must not contain path separators/);
216
+
217
+ const missingId = path.join(root, "missing-id");
218
+ writeJson(path.join(missingId, "package.json"), { openvcs: {} });
219
+ assert.throws(() => readManifest(missingId), /missing openvcs\.id/);
220
+
221
+ cleanupTempDir(root);
222
+ });
223
+
224
+ test("validateDeclaredModuleExec rejects invalid module paths", () => {
225
+ const root = makeTempDir("openvcs-sdk-test");
226
+ const pluginDir = path.join(root, "plugin");
227
+ fs.mkdirSync(path.join(pluginDir, "bin"), { recursive: true });
228
+
229
+ assert.doesNotThrow(() => validateDeclaredModuleExec(pluginDir, undefined));
230
+ assert.throws(() => validateDeclaredModuleExec(pluginDir, "native.node"), /must end with/);
231
+ assert.throws(() => validateDeclaredModuleExec(pluginDir, path.join(pluginDir, "bin", "x.js")), /must be a relative path/);
232
+ assert.throws(() => validateDeclaredModuleExec(pluginDir, "../escape.js"), /must point to a file under bin/);
233
+ assert.throws(() => validateDeclaredModuleExec(pluginDir, "missing.js"), /module entrypoint not found/);
234
+
235
+ cleanupTempDir(root);
236
+ });
237
+
238
+ test("renderGeneratedBootstrap rejects unsafe import paths", () => {
239
+ assert.throws(() => require("../lib/build").renderGeneratedBootstrap("./bad path.js", true), /unsafe module import path/);
240
+ });
241
+
242
+ test("build helpers handle no-op and package existence paths", () => {
243
+ const root = makeTempDir("openvcs-sdk-test");
244
+ const pluginDir = path.join(root, "plugin");
245
+ fs.mkdirSync(pluginDir, { recursive: true });
246
+
247
+ assert.equal(hasPackageJson(pluginDir), false);
248
+ writeJson(path.join(pluginDir, "package.json"), { openvcs: { id: "x" } });
249
+ assert.equal(hasPackageJson(pluginDir), true);
250
+ assert.equal(authoredPluginModulePath(pluginDir), path.join(pluginDir, "bin", "plugin.js"));
251
+ assert.doesNotThrow(() => generateModuleBootstrap(pluginDir, undefined));
252
+
253
+ cleanupTempDir(root);
254
+ });
255
+
256
+ test("runCommand reports spawn failures and non-zero exits", () => {
257
+ const root = makeTempDir("openvcs-sdk-test");
258
+
259
+ assert.doesNotThrow(() => runCommand(process.execPath, ["-e", "process.exit(0)"], root, true));
260
+ assert.throws(() => runCommand(process.execPath, ["-e", "process.exit(7)"], root, false), /exit code 7/);
261
+ assert.throws(() => runCommand(path.join(root, "missing-binary"), [], root, false), /failed to spawn/);
262
+
263
+ cleanupTempDir(root);
264
+ });
265
+
139
266
  test("validateGeneratedBootstrapTargets rejects module.exec collisions", () => {
140
267
  const root = makeTempDir("openvcs-sdk-test");
141
268
  const pluginDir = path.join(root, "plugin");
@@ -168,6 +295,30 @@ test("validateGeneratedBootstrapTargets rejects case-insensitive collisions", ()
168
295
  cleanupTempDir(root);
169
296
  });
170
297
 
298
+ test("validateGeneratedBootstrapTargets no-ops without module exec and rejects missing compiled module", () => {
299
+ const root = makeTempDir("openvcs-sdk-test");
300
+ const pluginDir = path.join(root, "plugin");
301
+ fs.mkdirSync(path.join(pluginDir, "bin"), { recursive: true });
302
+
303
+ assert.doesNotThrow(() => validateGeneratedBootstrapTargets(pluginDir, undefined));
304
+ assert.throws(() => validateGeneratedBootstrapTargets(pluginDir, "openvcs-plugin.js"), /compiled plugin module not found/);
305
+
306
+ cleanupTempDir(root);
307
+ });
308
+
309
+ test("generateModuleBootstrap tolerates invalid package json and uses extension for ESM", () => {
310
+ const root = makeTempDir("openvcs-sdk-test");
311
+ const pluginDir = path.join(root, "plugin");
312
+ writeText(path.join(pluginDir, "package.json"), "{");
313
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
314
+ writeText(path.join(pluginDir, "bin", "bootstrap.mjs"), "");
315
+
316
+ generateModuleBootstrap(pluginDir, "bootstrap.mjs");
317
+
318
+ assert.match(fs.readFileSync(path.join(pluginDir, "bin", "bootstrap.mjs"), "utf8"), /^import/m);
319
+ cleanupTempDir(root);
320
+ });
321
+
171
322
  test("generateModuleBootstrap handles subdirectory module.exec paths", () => {
172
323
  const root = makeTempDir("openvcs-sdk-test");
173
324
  const pluginDir = path.join(root, "plugin");
@@ -0,0 +1,57 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { runCli } = require("../lib/cli");
5
+
6
+ async function captureCli(args) {
7
+ const stdout = [];
8
+ const stderr = [];
9
+ const originalStdoutWrite = process.stdout.write;
10
+ const originalStderrWrite = process.stderr.write;
11
+ const originalExitCode = process.exitCode;
12
+ process.exitCode = undefined;
13
+ process.stdout.write = (chunk) => { stdout.push(String(chunk)); return true; };
14
+ process.stderr.write = (chunk) => { stderr.push(String(chunk)); return true; };
15
+ try {
16
+ await runCli(args);
17
+ return { stdout: stdout.join(""), stderr: stderr.join(""), exitCode: process.exitCode };
18
+ } finally {
19
+ process.stdout.write = originalStdoutWrite;
20
+ process.stderr.write = originalStderrWrite;
21
+ process.exitCode = originalExitCode;
22
+ }
23
+ }
24
+
25
+ test("runCli writes usage to stderr for empty args", async () => {
26
+ const result = await captureCli([]);
27
+
28
+ assert.match(result.stderr, /Usage: openvcs/);
29
+ assert.equal(result.exitCode, 1);
30
+ });
31
+
32
+ test("runCli writes root help to stdout", async () => {
33
+ const result = await captureCli(["help"]);
34
+
35
+ assert.match(result.stdout, /Commands:/);
36
+ assert.equal(result.exitCode, undefined);
37
+ });
38
+
39
+ test("runCli passes through build parser errors", async () => {
40
+ await assert.rejects(() => runCli(["build", "--plugin-dir"]), /missing value for --plugin-dir/);
41
+ });
42
+
43
+ test("runCli rejects invalid init arguments", async () => {
44
+ await assert.rejects(() => runCli(["init", "--bad"]), /unknown argument for init/);
45
+ });
46
+
47
+ test("runCli direct help branches write subcommand usage", async () => {
48
+ const buildHelp = await captureCli(["build", "--help"]);
49
+ const initHelp = await captureCli(["init", "--help"]);
50
+
51
+ assert.match(buildHelp.stdout, /openvcs build \[args\]/);
52
+ assert.match(initHelp.stdout, /Usage: openvcs init/);
53
+ });
54
+
55
+ test("runCli direct unknown command branch rejects", async () => {
56
+ await assert.rejects(() => runCli(["wat"]), /unknown command: wat/);
57
+ });
@@ -0,0 +1,104 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { createDefaultPluginDelegates, createHost, createRuntimeDispatcher, isPluginFailure, pluginError } = require("../lib/runtime");
5
+ const { PROTOCOL_VERSION } = require("../lib/types");
6
+
7
+ function writer() {
8
+ const messages = [];
9
+ return {
10
+ messages,
11
+ sendResult(id, result) {
12
+ messages.push({ id, result });
13
+ },
14
+ sendError(id, code, message, data) {
15
+ messages.push({ id, error: { code, message, data } });
16
+ },
17
+ };
18
+ }
19
+
20
+ test("default plugin delegates return protocol-safe defaults", async () => {
21
+ const delegates = createDefaultPluginDelegates();
22
+
23
+ assert.equal(await delegates["plugin.init"]({}, {}), null);
24
+ assert.equal(await delegates["plugin.deinit"]({}, {}), null);
25
+ assert.deepEqual(await delegates["plugin.get_menus"]({}, {}), []);
26
+ assert.equal(await delegates["plugin.handle_action"]({}, {}), null);
27
+ assert.deepEqual(await delegates["plugin.settings.defaults"]({}, {}), []);
28
+ assert.deepEqual(await delegates["plugin.settings.on_load"]({ values: [1] }, {}), [1]);
29
+ assert.deepEqual(await delegates["plugin.settings.on_load"]({}, {}), []);
30
+ assert.equal(await delegates["plugin.settings.on_apply"]({}, {}), null);
31
+ assert.deepEqual(await delegates["plugin.settings.on_save"]({ values: [2] }, {}), [2]);
32
+ assert.deepEqual(await delegates["plugin.settings.on_save"]({}, {}), []);
33
+ assert.equal(await delegates["plugin.settings.on_reset"]({}, {}), null);
34
+ });
35
+
36
+ test("dispatcher rejects protocol version mismatch", async () => {
37
+ const out = writer();
38
+ const dispatcher = createRuntimeDispatcher({}, createHost(() => {}), out);
39
+
40
+ await dispatcher(1, "plugin.initialize", { expected_protocol_version: PROTOCOL_VERSION + 1 });
41
+
42
+ assert.equal(out.messages[0].id, 1);
43
+ assert.equal(out.messages[0].error.data.code, "protocol-version-mismatch");
44
+ });
45
+
46
+ test("dispatcher merges initialize override with inferred implements", async () => {
47
+ const out = writer();
48
+ const dispatcher = createRuntimeDispatcher(
49
+ {
50
+ implements: { plugin: { menus: true } },
51
+ vcs: { "vcs.status": async () => ({ files: [] }) },
52
+ plugin: {
53
+ "plugin.initialize": async () => ({ implements: { custom: true } }),
54
+ },
55
+ },
56
+ createHost(() => {}),
57
+ out,
58
+ );
59
+
60
+ await dispatcher("init", "plugin.initialize", { expected_protocol_version: PROTOCOL_VERSION });
61
+
62
+ assert.equal(out.messages[0].result.protocol_version, PROTOCOL_VERSION);
63
+ assert.equal(out.messages[0].result.implements.custom, true);
64
+ assert.equal(out.messages[0].result.implements.vcs, true);
65
+ });
66
+
67
+ test("dispatcher reports plugin failures separately from generic failures", async () => {
68
+ const pluginOut = writer();
69
+ await createRuntimeDispatcher(
70
+ { plugin: { "plugin.init": async () => { throw pluginError("bad-input", "Bad input", { field: "x" }); } } },
71
+ createHost(() => {}),
72
+ pluginOut,
73
+ )(2, "plugin.init", {});
74
+
75
+ const genericOut = writer();
76
+ await createRuntimeDispatcher(
77
+ { plugin: { "plugin.init": async () => { throw new Error("Boom"); } } },
78
+ createHost(() => {}),
79
+ genericOut,
80
+ )(3, "plugin.init", {});
81
+
82
+ assert.equal(pluginOut.messages[0].error.data.code, "bad-input");
83
+ assert.equal(genericOut.messages[0].error.data.code, "plugin-internal-error");
84
+ });
85
+
86
+ test("isPluginFailure rejects non-objects", () => {
87
+ assert.equal(isPluginFailure(null), false);
88
+ assert.equal(isPluginFailure("bad"), false);
89
+ assert.equal(isPluginFailure({ code: "x", message: "y" }), false);
90
+ });
91
+
92
+ test("dispatcher times out slow handlers", async () => {
93
+ const out = writer();
94
+ const dispatcher = createRuntimeDispatcher(
95
+ { timeout: 1, plugin: { "plugin.init": async () => new Promise(() => {}) } },
96
+ createHost(() => {}),
97
+ out,
98
+ );
99
+
100
+ await dispatcher(4, "plugin.init", {});
101
+
102
+ assert.equal(out.messages[0].error.data.code, "request-timeout");
103
+ assert.match(out.messages[0].error.message, /timed out/);
104
+ });
@@ -55,6 +55,20 @@ test("copyFileStrict rejects non-files", () => {
55
55
  cleanupTempDir(root);
56
56
  });
57
57
 
58
+ test("copyFileStrict rejects file symlinks", () => {
59
+ if (process.platform === "win32") {
60
+ return;
61
+ }
62
+ const root = makeTempDir("openvcs-sdk-test");
63
+ const target = path.join(root, "target.txt");
64
+ const link = path.join(root, "link.txt");
65
+ writeText(target, "target");
66
+ fs.symlinkSync(target, link);
67
+
68
+ assert.throws(() => copyFileStrict(link, path.join(root, "out.txt")), /symlink/);
69
+ cleanupTempDir(root);
70
+ });
71
+
58
72
  test("copyDirectoryRecursiveStrict copies nested trees", () => {
59
73
  const root = makeTempDir("openvcs-sdk-test");
60
74
  const src = path.join(root, "src");
@@ -87,6 +101,49 @@ test("copyDirectoryRecursiveStrict errors when source is file", () => {
87
101
  cleanupTempDir(root);
88
102
  });
89
103
 
104
+ test("copyDirectoryRecursiveStrict rejects source directory symlink", () => {
105
+ if (process.platform === "win32") {
106
+ return;
107
+ }
108
+ const root = makeTempDir("openvcs-sdk-test");
109
+ const targetDir = path.join(root, "target");
110
+ const link = path.join(root, "link");
111
+ fs.mkdirSync(targetDir, { recursive: true });
112
+ fs.symlinkSync(targetDir, link);
113
+
114
+ assert.throws(() => copyDirectoryRecursiveStrict(link, path.join(root, "dst")), /symlink/);
115
+ cleanupTempDir(root);
116
+ });
117
+
118
+ test("copyDirectoryRecursiveStrict skips special non-file entries", () => {
119
+ const root = makeTempDir("openvcs-sdk-test");
120
+ const src = path.join(root, "src");
121
+ const dst = path.join(root, "dst");
122
+ fs.mkdirSync(path.join(src, "fifo-like"), { recursive: true });
123
+ writeText(path.join(src, "file.txt"), "ok");
124
+
125
+ copyDirectoryRecursiveStrict(src, dst);
126
+
127
+ assert.equal(fs.readFileSync(path.join(dst, "file.txt"), "utf8"), "ok");
128
+ cleanupTempDir(root);
129
+ });
130
+
131
+ test("copyDirectoryRecursiveStrict rejects symlink entries inside trees", () => {
132
+ if (process.platform === "win32") {
133
+ return;
134
+ }
135
+ const root = makeTempDir("openvcs-sdk-test");
136
+ const src = path.join(root, "src");
137
+ const dst = path.join(root, "dst");
138
+ const target = path.join(root, "target.txt");
139
+ writeText(target, "target");
140
+ fs.mkdirSync(src, { recursive: true });
141
+ fs.symlinkSync(target, path.join(src, "linked.txt"));
142
+
143
+ assert.throws(() => copyDirectoryRecursiveStrict(src, dst), /symlink/);
144
+ cleanupTempDir(root);
145
+ });
146
+
90
147
  test("rejectSymlinksRecursive allows regular trees", () => {
91
148
  const root = makeTempDir("openvcs-sdk-test");
92
149
  writeText(path.join(root, "a.txt"), "a");
@@ -0,0 +1,39 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { createHost } = require("../lib/runtime");
5
+
6
+ test("createHost maps log helpers to host.log notifications", () => {
7
+ const sent = [];
8
+ const host = createHost((method, params) => sent.push({ method, params }), { logTarget: "test.plugin" });
9
+
10
+ host.log("warn", "careful");
11
+ host.info("ready");
12
+ host.error("failed");
13
+
14
+ assert.deepEqual(sent, [
15
+ { method: "host.log", params: { level: "warn", target: "test.plugin", message: "careful" } },
16
+ { method: "host.log", params: { level: "info", target: "test.plugin", message: "ready" } },
17
+ { method: "host.log", params: { level: "error", target: "test.plugin", message: "failed" } },
18
+ ]);
19
+ });
20
+
21
+ test("createHost maps UI/status/event helpers to protocol notifications", () => {
22
+ const sent = [];
23
+ const host = createHost((method, params) => sent.push({ method, params }));
24
+
25
+ host.uiNotify({ level: "info", message: "Saved" });
26
+ host.statusSet({ message: "Working" });
27
+ host.emitEvent({ name: "custom", payload: { value: 1 } });
28
+ host.emitVcsEvent("session-1", 42, { type: "progress", message: "Fetch" });
29
+
30
+ assert.deepEqual(sent, [
31
+ { method: "host.ui_notify", params: { level: "info", message: "Saved" } },
32
+ { method: "host.status_set", params: { message: "Working" } },
33
+ { method: "host.event_emit", params: { name: "custom", payload: { value: 1 } } },
34
+ {
35
+ method: "vcs.event",
36
+ params: { session_id: "session-1", request_id: 42, event: { type: "progress", message: "Fetch" } },
37
+ },
38
+ ]);
39
+ });