@openvcs/sdk 0.2.0 → 0.2.2

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.
@@ -0,0 +1,456 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ const { bundlePlugin, parseDistArgs, __private } = require("../lib/dist");
7
+ const {
8
+ cleanupTempDir,
9
+ makeTempDir,
10
+ readBundleEntries,
11
+ writeJson,
12
+ writeText,
13
+ } = require("./helpers");
14
+
15
+ test("parseDistArgs uses defaults", () => {
16
+ const parsed = parseDistArgs([]);
17
+ assert.equal(parsed.pluginDir, process.cwd());
18
+ assert.equal(parsed.outDir, path.resolve("dist"));
19
+ assert.equal(parsed.verbose, false);
20
+ assert.equal(parsed.noNpmDeps, false);
21
+ });
22
+
23
+ test("parseDistArgs parses known flags", () => {
24
+ const parsed = parseDistArgs([
25
+ "--plugin-dir",
26
+ "some/plugin",
27
+ "--out",
28
+ "some/out",
29
+ "--no-npm-deps",
30
+ "--verbose",
31
+ ]);
32
+ assert.equal(parsed.pluginDir, path.resolve("some/plugin"));
33
+ assert.equal(parsed.outDir, path.resolve("some/out"));
34
+ assert.equal(parsed.noNpmDeps, true);
35
+ assert.equal(parsed.verbose, true);
36
+ });
37
+
38
+ test("parseDistArgs rejects unknown flag", () => {
39
+ assert.throws(() => parseDistArgs(["--nope"]), /unknown flag: --nope/);
40
+ });
41
+
42
+ test("parseDistArgs requires plugin-dir value", () => {
43
+ assert.throws(() => parseDistArgs(["--plugin-dir"]), /missing value for --plugin-dir/);
44
+ });
45
+
46
+ test("parseDistArgs requires out value", () => {
47
+ assert.throws(() => parseDistArgs(["--out"]), /missing value for --out/);
48
+ });
49
+
50
+ test("parseDistArgs help returns usage error", () => {
51
+ assert.throws(() => parseDistArgs(["--help"]), /openvcs dist \[args\]/);
52
+ });
53
+
54
+ test("readManifest parses and trims fields", () => {
55
+ const root = makeTempDir("openvcs-sdk-test");
56
+ const pluginDir = path.join(root, "plugin");
57
+ writeText(
58
+ path.join(pluginDir, "openvcs.plugin.json"),
59
+ '{\n "id": " my.plugin ",\n "module": { "exec": " module.mjs " }\n}\n'
60
+ );
61
+
62
+ const parsed = __private.readManifest(pluginDir);
63
+ assert.equal(parsed.pluginId, "my.plugin");
64
+ assert.equal(parsed.moduleExec, "module.mjs");
65
+
66
+ cleanupTempDir(root);
67
+ });
68
+
69
+ test("readManifest errors for missing manifest", () => {
70
+ const root = makeTempDir("openvcs-sdk-test");
71
+ const pluginDir = path.join(root, "plugin");
72
+ fs.mkdirSync(pluginDir, { recursive: true });
73
+ assert.throws(() => __private.readManifest(pluginDir), /missing openvcs\.plugin\.json/);
74
+ cleanupTempDir(root);
75
+ });
76
+
77
+ test("readManifest errors for malformed JSON with path", () => {
78
+ const root = makeTempDir("openvcs-sdk-test");
79
+ const pluginDir = path.join(root, "plugin");
80
+ writeText(path.join(pluginDir, "openvcs.plugin.json"), "{");
81
+
82
+ assert.throws(
83
+ () => __private.readManifest(pluginDir),
84
+ new RegExp(`parse ${pluginDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)
85
+ );
86
+
87
+ cleanupTempDir(root);
88
+ });
89
+
90
+ test("readManifest errors for empty id", () => {
91
+ const root = makeTempDir("openvcs-sdk-test");
92
+ const pluginDir = path.join(root, "plugin");
93
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), { id: " " });
94
+ assert.throws(() => __private.readManifest(pluginDir), /missing a string 'id'/);
95
+ cleanupTempDir(root);
96
+ });
97
+
98
+ test("readManifest rejects id with path separators", () => {
99
+ const root = makeTempDir("openvcs-sdk-test");
100
+ const pluginDir = path.join(root, "plugin");
101
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), { id: "bad/id" });
102
+ assert.throws(() => __private.readManifest(pluginDir), /must not contain path separators/);
103
+ cleanupTempDir(root);
104
+ });
105
+
106
+ test("validateDeclaredModuleExec accepts .js/.mjs/.cjs", () => {
107
+ const root = makeTempDir("openvcs-sdk-test");
108
+ const pluginDir = path.join(root, "plugin");
109
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
110
+ writeText(path.join(pluginDir, "bin", "plugin.mjs"), "export {};\n");
111
+ writeText(path.join(pluginDir, "bin", "plugin.cjs"), "module.exports = {};\n");
112
+
113
+ assert.doesNotThrow(() => __private.validateDeclaredModuleExec(pluginDir, "plugin.js"));
114
+ assert.doesNotThrow(() => __private.validateDeclaredModuleExec(pluginDir, "plugin.mjs"));
115
+ assert.doesNotThrow(() => __private.validateDeclaredModuleExec(pluginDir, "plugin.cjs"));
116
+
117
+ cleanupTempDir(root);
118
+ });
119
+
120
+ test("validateDeclaredModuleExec rejects non-node extension", () => {
121
+ const root = makeTempDir("openvcs-sdk-test");
122
+ const pluginDir = path.join(root, "plugin");
123
+ writeText(path.join(pluginDir, "bin", "plugin.ts"), "export {};\n");
124
+ assert.throws(
125
+ () => __private.validateDeclaredModuleExec(pluginDir, "plugin.ts"),
126
+ /must end with .js\/.mjs\/.cjs/
127
+ );
128
+ cleanupTempDir(root);
129
+ });
130
+
131
+ test("validateDeclaredModuleExec rejects absolute path", () => {
132
+ const root = makeTempDir("openvcs-sdk-test");
133
+ const pluginDir = path.join(root, "plugin");
134
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
135
+ assert.throws(
136
+ () => __private.validateDeclaredModuleExec(pluginDir, path.resolve(pluginDir, "bin", "plugin.js")),
137
+ /must be a relative path under bin/
138
+ );
139
+ cleanupTempDir(root);
140
+ });
141
+
142
+ test("validateDeclaredModuleExec rejects path traversal", () => {
143
+ const root = makeTempDir("openvcs-sdk-test");
144
+ const pluginDir = path.join(root, "plugin");
145
+ writeText(path.join(pluginDir, "secret.js"), "export {};\n");
146
+ assert.throws(
147
+ () => __private.validateDeclaredModuleExec(pluginDir, "../secret.js"),
148
+ /must point to a file under bin/
149
+ );
150
+ cleanupTempDir(root);
151
+ });
152
+
153
+ test("validateDeclaredModuleExec errors when file missing", () => {
154
+ const root = makeTempDir("openvcs-sdk-test");
155
+ const pluginDir = path.join(root, "plugin");
156
+ fs.mkdirSync(path.join(pluginDir, "bin"), { recursive: true });
157
+ assert.throws(
158
+ () => __private.validateDeclaredModuleExec(pluginDir, "missing.mjs"),
159
+ /module entrypoint not found/
160
+ );
161
+ cleanupTempDir(root);
162
+ });
163
+
164
+ test("copyIcon prefers png over jpg", () => {
165
+ const root = makeTempDir("openvcs-sdk-test");
166
+ const pluginDir = path.join(root, "plugin");
167
+ const bundleDir = path.join(root, "bundle");
168
+ fs.mkdirSync(bundleDir, { recursive: true });
169
+ writeText(path.join(pluginDir, "icon.jpg"), "jpg");
170
+ writeText(path.join(pluginDir, "icon.png"), "png");
171
+
172
+ __private.copyIcon(pluginDir, bundleDir);
173
+
174
+ assert.equal(fs.readFileSync(path.join(bundleDir, "icon.png"), "utf8"), "png");
175
+ assert.equal(fs.existsSync(path.join(bundleDir, "icon.jpg")), false);
176
+ cleanupTempDir(root);
177
+ });
178
+
179
+ test("copyIcon is no-op when icon is absent", () => {
180
+ const root = makeTempDir("openvcs-sdk-test");
181
+ const pluginDir = path.join(root, "plugin");
182
+ const bundleDir = path.join(root, "bundle");
183
+ fs.mkdirSync(pluginDir, { recursive: true });
184
+ fs.mkdirSync(bundleDir, { recursive: true });
185
+
186
+ __private.copyIcon(pluginDir, bundleDir);
187
+
188
+ for (const ext of __private.ICON_EXTENSIONS) {
189
+ assert.equal(fs.existsSync(path.join(bundleDir, `icon.${ext}`)), false);
190
+ }
191
+
192
+ cleanupTempDir(root);
193
+ });
194
+
195
+ test("uniqueStagingDir uses expected prefix", () => {
196
+ const staging = __private.uniqueStagingDir("/tmp/output");
197
+ assert.match(path.basename(staging), /^\.openvcs-plugin-staging-/);
198
+ });
199
+
200
+ test("writeTarGz creates a valid gzip tar", async () => {
201
+ const root = makeTempDir("openvcs-sdk-test");
202
+ const baseDir = path.join(root, "base");
203
+ const outPath = path.join(root, "bundle.ovcsp");
204
+ writeText(path.join(baseDir, "myplugin", "file.txt"), "content");
205
+ writeText(path.join(baseDir, "myplugin", "sub", "nested.txt"), "nested");
206
+
207
+ await __private.writeTarGz(outPath, baseDir, "myplugin");
208
+ const entries = await readBundleEntries(outPath);
209
+
210
+ assert.equal(entries.has("myplugin/file.txt"), true);
211
+ assert.equal(entries.has("myplugin/sub/nested.txt"), true);
212
+ cleanupTempDir(root);
213
+ });
214
+
215
+ test("rejectNativeAddonsRecursive rejects .node files", () => {
216
+ const root = makeTempDir("openvcs-sdk-test");
217
+ const modulesDir = path.join(root, "node_modules");
218
+ writeText(path.join(modulesDir, "pkg", "addon.node"), "binary");
219
+ assert.throws(() => __private.rejectNativeAddonsRecursive(modulesDir), /native Node addon/);
220
+ cleanupTempDir(root);
221
+ });
222
+
223
+ test("rejectNativeAddonsRecursive allows normal files", () => {
224
+ const root = makeTempDir("openvcs-sdk-test");
225
+ const modulesDir = path.join(root, "node_modules");
226
+ writeText(path.join(modulesDir, "pkg", "index.js"), "module.exports = {};\n");
227
+ assert.doesNotThrow(() => __private.rejectNativeAddonsRecursive(modulesDir));
228
+ cleanupTempDir(root);
229
+ });
230
+
231
+ test("bundlePlugin writes a gzip .ovcsp for themes-only plugin", async () => {
232
+ const root = makeTempDir("openvcs-sdk-test");
233
+ const pluginDir = path.join(root, "plugin");
234
+ const outDir = path.join(root, "out");
235
+
236
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), { id: "ui-only" });
237
+ writeText(path.join(pluginDir, "themes", "default", "theme.json"), '{"name":"test"}\n');
238
+ writeText(path.join(pluginDir, "icon.png"), "icon-bytes");
239
+
240
+ const outPath = await bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true });
241
+ const entries = await readBundleEntries(outPath);
242
+
243
+ assert.equal(path.basename(outPath), "ui-only.ovcsp");
244
+ assert.equal(entries.has("ui-only/openvcs.plugin.json"), true);
245
+ assert.equal(entries.has("ui-only/themes/default/theme.json"), true);
246
+ assert.equal(entries.has("ui-only/icon.png"), true);
247
+
248
+ cleanupTempDir(root);
249
+ });
250
+
251
+ test("bundlePlugin rejects manifest with no module and no themes", async () => {
252
+ const root = makeTempDir("openvcs-sdk-test");
253
+ const pluginDir = path.join(root, "plugin");
254
+ const outDir = path.join(root, "out");
255
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), { id: "x" });
256
+
257
+ await assert.rejects(
258
+ () => bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true }),
259
+ /manifest has no module\.exec or themes\//
260
+ );
261
+
262
+ cleanupTempDir(root);
263
+ });
264
+
265
+ test("bundlePlugin trims module.exec and includes extra bin files", async () => {
266
+ const root = makeTempDir("openvcs-sdk-test");
267
+ const pluginDir = path.join(root, "plugin");
268
+ const outDir = path.join(root, "out");
269
+
270
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
271
+ id: "x",
272
+ module: { exec: " module.mjs " },
273
+ });
274
+ writeText(path.join(pluginDir, "bin", "module.mjs"), "export {};\n");
275
+ writeText(path.join(pluginDir, "bin", "helpers", "util.mjs"), "export const x = 1;\n");
276
+
277
+ const outPath = await bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true });
278
+ const entries = await readBundleEntries(outPath);
279
+
280
+ assert.equal(entries.has("x/bin/module.mjs"), true);
281
+ assert.equal(entries.has("x/bin/helpers/util.mjs"), true);
282
+ cleanupTempDir(root);
283
+ });
284
+
285
+ test("bundlePlugin rejects module.exec path traversal", async () => {
286
+ const root = makeTempDir("openvcs-sdk-test");
287
+ const pluginDir = path.join(root, "plugin");
288
+ const outDir = path.join(root, "out");
289
+
290
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
291
+ id: "bad",
292
+ module: { exec: "../secret.js" },
293
+ });
294
+ writeText(path.join(pluginDir, "secret.js"), "console.log('secret')\n");
295
+
296
+ await assert.rejects(
297
+ () => bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true }),
298
+ /module\.exec/
299
+ );
300
+
301
+ cleanupTempDir(root);
302
+ });
303
+
304
+ test("bundlePlugin rejects non-node module.exec extension", async () => {
305
+ const root = makeTempDir("openvcs-sdk-test");
306
+ const pluginDir = path.join(root, "plugin");
307
+ const outDir = path.join(root, "out");
308
+
309
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
310
+ id: "bad",
311
+ module: { exec: "plugin.ts" },
312
+ });
313
+ writeText(path.join(pluginDir, "bin", "plugin.ts"), "export {};\n");
314
+
315
+ await assert.rejects(
316
+ () => bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true }),
317
+ /must end with .js\/.mjs\/.cjs/
318
+ );
319
+
320
+ cleanupTempDir(root);
321
+ });
322
+
323
+ test("bundlePlugin rejects plugin id with path separators", async () => {
324
+ const root = makeTempDir("openvcs-sdk-test");
325
+ const pluginDir = path.join(root, "plugin");
326
+ const outDir = path.join(root, "out");
327
+
328
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
329
+ id: "bad/id",
330
+ module: { exec: "plugin.js" },
331
+ });
332
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
333
+
334
+ await assert.rejects(
335
+ () => bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true }),
336
+ /must not contain path separators/
337
+ );
338
+
339
+ cleanupTempDir(root);
340
+ });
341
+
342
+ test("bundlePlugin rejects symlink in bin", async () => {
343
+ if (process.platform === "win32") {
344
+ return;
345
+ }
346
+
347
+ const root = makeTempDir("openvcs-sdk-test");
348
+ const pluginDir = path.join(root, "plugin");
349
+ const outDir = path.join(root, "out");
350
+
351
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
352
+ id: "x",
353
+ module: { exec: "plugin.js" },
354
+ });
355
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
356
+ writeText(path.join(pluginDir, "bin", "target.js"), "export {};\n");
357
+ fs.symlinkSync(path.join(pluginDir, "bin", "target.js"), path.join(pluginDir, "bin", "link.js"));
358
+
359
+ await assert.rejects(
360
+ () => bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true }),
361
+ /symlink/
362
+ );
363
+
364
+ cleanupTempDir(root);
365
+ });
366
+
367
+ test("bundlePlugin output archive keeps plugin-id root directory", async () => {
368
+ const root = makeTempDir("openvcs-sdk-test");
369
+ const pluginDir = path.join(root, "plugin");
370
+ const outDir = path.join(root, "out");
371
+
372
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
373
+ id: "root-check",
374
+ module: { exec: "plugin.js" },
375
+ });
376
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
377
+
378
+ const outPath = await bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true });
379
+ const entries = await readBundleEntries(outPath);
380
+ for (const key of entries.keys()) {
381
+ assert.equal(key.startsWith("root-check/"), true);
382
+ }
383
+
384
+ cleanupTempDir(root);
385
+ });
386
+
387
+ test("bundlePlugin overwrites existing bundle file", async () => {
388
+ const root = makeTempDir("openvcs-sdk-test");
389
+ const pluginDir = path.join(root, "plugin");
390
+ const outDir = path.join(root, "out");
391
+ fs.mkdirSync(outDir, { recursive: true });
392
+
393
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
394
+ id: "replace",
395
+ module: { exec: "plugin.js" },
396
+ });
397
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
398
+
399
+ const existingPath = path.join(outDir, "replace.ovcsp");
400
+ writeText(existingPath, "not-a-tar");
401
+
402
+ const outPath = await bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true });
403
+ const entries = await readBundleEntries(outPath);
404
+ assert.equal(entries.has("replace/openvcs.plugin.json"), true);
405
+
406
+ cleanupTempDir(root);
407
+ });
408
+
409
+ test("bundlePlugin generates package-lock when package.json exists", async () => {
410
+ const root = makeTempDir("openvcs-sdk-test");
411
+ const pluginDir = path.join(root, "plugin");
412
+ const outDir = path.join(root, "out");
413
+
414
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
415
+ id: "npm-plugin",
416
+ module: { exec: "plugin.js" },
417
+ });
418
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
419
+ writeJson(path.join(pluginDir, "package.json"), {
420
+ name: "npm-plugin",
421
+ version: "0.1.0",
422
+ private: true,
423
+ });
424
+
425
+ const outPath = await bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: false });
426
+ const entries = await readBundleEntries(outPath);
427
+
428
+ assert.equal(fs.existsSync(path.join(pluginDir, "package-lock.json")), true);
429
+ assert.equal(entries.has("npm-plugin/package.json"), true);
430
+ assert.equal(entries.has("npm-plugin/package-lock.json"), true);
431
+
432
+ cleanupTempDir(root);
433
+ });
434
+
435
+ test("bundlePlugin with --no-npm-deps does not generate lockfile", async () => {
436
+ const root = makeTempDir("openvcs-sdk-test");
437
+ const pluginDir = path.join(root, "plugin");
438
+ const outDir = path.join(root, "out");
439
+
440
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
441
+ id: "no-npm",
442
+ module: { exec: "plugin.js" },
443
+ });
444
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
445
+ writeJson(path.join(pluginDir, "package.json"), {
446
+ name: "no-npm",
447
+ version: "0.1.0",
448
+ private: true,
449
+ });
450
+
451
+ await bundlePlugin({ pluginDir, outDir, verbose: false, noNpmDeps: true });
452
+
453
+ assert.equal(fs.existsSync(path.join(pluginDir, "package-lock.json")), false);
454
+
455
+ cleanupTempDir(root);
456
+ });
@@ -0,0 +1,124 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ const {
7
+ copyDirectoryRecursiveStrict,
8
+ copyFileStrict,
9
+ ensureDirectory,
10
+ isPathInside,
11
+ rejectSymlinksRecursive,
12
+ } = require("../lib/fs-utils");
13
+ const { cleanupTempDir, makeTempDir, writeText } = require("./helpers");
14
+
15
+ test("isPathInside returns true for descendants", () => {
16
+ const root = "/tmp/root";
17
+ assert.equal(isPathInside(root, "/tmp/root/a/b"), true);
18
+ });
19
+
20
+ test("isPathInside returns true for root itself", () => {
21
+ const root = "/tmp/root";
22
+ assert.equal(isPathInside(root, "/tmp/root"), true);
23
+ });
24
+
25
+ test("isPathInside returns false for parent escape", () => {
26
+ const root = "/tmp/root";
27
+ assert.equal(isPathInside(root, "/tmp/other/file"), false);
28
+ });
29
+
30
+ test("ensureDirectory creates nested directories", () => {
31
+ const root = makeTempDir("openvcs-sdk-test");
32
+ const target = path.join(root, "a", "b", "c");
33
+ ensureDirectory(target);
34
+ assert.equal(fs.existsSync(target), true);
35
+ assert.equal(fs.lstatSync(target).isDirectory(), true);
36
+ cleanupTempDir(root);
37
+ });
38
+
39
+ test("copyFileStrict copies regular files", () => {
40
+ const root = makeTempDir("openvcs-sdk-test");
41
+ const src = path.join(root, "src.txt");
42
+ const dst = path.join(root, "nested", "dst.txt");
43
+ writeText(src, "hello");
44
+ copyFileStrict(src, dst);
45
+ assert.equal(fs.readFileSync(dst, "utf8"), "hello");
46
+ cleanupTempDir(root);
47
+ });
48
+
49
+ test("copyFileStrict rejects non-files", () => {
50
+ const root = makeTempDir("openvcs-sdk-test");
51
+ const src = path.join(root, "src-dir");
52
+ fs.mkdirSync(src, { recursive: true });
53
+ const dst = path.join(root, "dst.txt");
54
+ assert.throws(() => copyFileStrict(src, dst), /expected file/);
55
+ cleanupTempDir(root);
56
+ });
57
+
58
+ test("copyDirectoryRecursiveStrict copies nested trees", () => {
59
+ const root = makeTempDir("openvcs-sdk-test");
60
+ const src = path.join(root, "src");
61
+ const dst = path.join(root, "dst");
62
+ writeText(path.join(src, "a.txt"), "a");
63
+ writeText(path.join(src, "nested", "b.txt"), "b");
64
+
65
+ copyDirectoryRecursiveStrict(src, dst);
66
+
67
+ assert.equal(fs.readFileSync(path.join(dst, "a.txt"), "utf8"), "a");
68
+ assert.equal(fs.readFileSync(path.join(dst, "nested", "b.txt"), "utf8"), "b");
69
+ cleanupTempDir(root);
70
+ });
71
+
72
+ test("copyDirectoryRecursiveStrict is no-op for missing source", () => {
73
+ const root = makeTempDir("openvcs-sdk-test");
74
+ const src = path.join(root, "missing");
75
+ const dst = path.join(root, "dst");
76
+ copyDirectoryRecursiveStrict(src, dst);
77
+ assert.equal(fs.existsSync(dst), false);
78
+ cleanupTempDir(root);
79
+ });
80
+
81
+ test("copyDirectoryRecursiveStrict errors when source is file", () => {
82
+ const root = makeTempDir("openvcs-sdk-test");
83
+ const src = path.join(root, "file.txt");
84
+ writeText(src, "x");
85
+ const dst = path.join(root, "dst");
86
+ assert.throws(() => copyDirectoryRecursiveStrict(src, dst), /expected directory/);
87
+ cleanupTempDir(root);
88
+ });
89
+
90
+ test("rejectSymlinksRecursive allows regular trees", () => {
91
+ const root = makeTempDir("openvcs-sdk-test");
92
+ writeText(path.join(root, "a.txt"), "a");
93
+ writeText(path.join(root, "nested", "b.txt"), "b");
94
+ assert.doesNotThrow(() => rejectSymlinksRecursive(root));
95
+ cleanupTempDir(root);
96
+ });
97
+
98
+ test("rejectSymlinksRecursive rejects file symlink", () => {
99
+ if (process.platform === "win32") {
100
+ return;
101
+ }
102
+ const root = makeTempDir("openvcs-sdk-test");
103
+ const target = path.join(root, "target.txt");
104
+ const link = path.join(root, "link.txt");
105
+ writeText(target, "target");
106
+ fs.symlinkSync(target, link);
107
+
108
+ assert.throws(() => rejectSymlinksRecursive(root), /symlink/);
109
+ cleanupTempDir(root);
110
+ });
111
+
112
+ test("rejectSymlinksRecursive rejects directory symlink", () => {
113
+ if (process.platform === "win32") {
114
+ return;
115
+ }
116
+ const root = makeTempDir("openvcs-sdk-test");
117
+ const targetDir = path.join(root, "target-dir");
118
+ const link = path.join(root, "link-dir");
119
+ fs.mkdirSync(targetDir, { recursive: true });
120
+ fs.symlinkSync(targetDir, link);
121
+
122
+ assert.throws(() => rejectSymlinksRecursive(root), /symlink/);
123
+ cleanupTempDir(root);
124
+ });
@@ -0,0 +1,49 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const tar = require("tar");
5
+
6
+ function makeTempDir(prefix = "openvcs-sdk-test") {
7
+ return fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
8
+ }
9
+
10
+ function cleanupTempDir(rootDir) {
11
+ fs.rmSync(rootDir, { recursive: true, force: true });
12
+ }
13
+
14
+ function writeJson(filePath, value) {
15
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
16
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
17
+ }
18
+
19
+ function writeText(filePath, text) {
20
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
21
+ fs.writeFileSync(filePath, text, "utf8");
22
+ }
23
+
24
+ async function readBundleEntries(bundlePath) {
25
+ const entries = new Map();
26
+ await tar.t({
27
+ file: bundlePath,
28
+ gzip: true,
29
+ onentry(entry) {
30
+ if (entry.type !== "File") {
31
+ return;
32
+ }
33
+ const chunks = [];
34
+ entry.on("data", (chunk) => chunks.push(chunk));
35
+ entry.on("end", () => {
36
+ entries.set(entry.path, Buffer.concat(chunks));
37
+ });
38
+ },
39
+ });
40
+ return entries;
41
+ }
42
+
43
+ module.exports = {
44
+ cleanupTempDir,
45
+ makeTempDir,
46
+ readBundleEntries,
47
+ writeJson,
48
+ writeText,
49
+ };
@@ -0,0 +1,65 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const path = require("node:path");
4
+
5
+ const { __private } = require("../lib/init");
6
+
7
+ test("validatePluginId accepts regular ids", () => {
8
+ assert.equal(__private.validatePluginId("my.plugin"), undefined);
9
+ assert.equal(__private.validatePluginId("my-plugin_1"), undefined);
10
+ });
11
+
12
+ test("validatePluginId rejects empty id", () => {
13
+ assert.match(__private.validatePluginId(""), /required/);
14
+ });
15
+
16
+ test("validatePluginId rejects dot segments", () => {
17
+ assert.match(__private.validatePluginId("."), /must not be/);
18
+ assert.match(__private.validatePluginId(".."), /must not be/);
19
+ });
20
+
21
+ test("validatePluginId rejects path separators", () => {
22
+ assert.match(__private.validatePluginId("bad/id"), /path separators/);
23
+ assert.match(__private.validatePluginId("bad\\id"), /path separators/);
24
+ });
25
+
26
+ test("collectAnswers re-prompts invalid plugin id", async () => {
27
+ const prompts = [
28
+ "plugin-dir",
29
+ "module",
30
+ "bad/id",
31
+ "good-id",
32
+ "Good Plugin",
33
+ "0.2.0",
34
+ ];
35
+ const booleans = [true, false];
36
+ const messages = [];
37
+
38
+ const promptDriver = {
39
+ async promptText() {
40
+ return prompts.shift() || "";
41
+ },
42
+ async promptBoolean() {
43
+ return booleans.shift() || false;
44
+ },
45
+ close() {},
46
+ };
47
+
48
+ const output = {
49
+ write(message) {
50
+ messages.push(message);
51
+ },
52
+ };
53
+
54
+ const answers = await __private.collectAnswers(
55
+ { forceTheme: false, targetHint: "plugin-dir" },
56
+ promptDriver,
57
+ output
58
+ );
59
+
60
+ assert.equal(answers.pluginId, "good-id");
61
+ assert.equal(answers.targetDir, path.resolve("plugin-dir"));
62
+ assert.equal(answers.defaultEnabled, true);
63
+ assert.equal(answers.runNpmInstall, false);
64
+ assert.equal(messages.some((message) => message.includes("must not contain path separators")), true);
65
+ });