@openvcs/sdk 0.2.1 → 0.2.3

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/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,27 +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
- stdout?: string | null;
34
- stderr?: string | null;
35
- }
36
-
37
- function npmExecutable(): string {
38
- return process.platform === "win32" ? "npm.cmd" : "npm";
30
+ noBuild: boolean;
39
31
  }
40
32
 
41
33
  export function distUsage(commandName = "openvcs"): string {
42
- 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`;
43
35
  }
44
36
 
45
37
  export function parseDistArgs(args: string[]): DistArgs {
@@ -47,6 +39,7 @@ export function parseDistArgs(args: string[]): DistArgs {
47
39
  let outDir = "dist";
48
40
  let verbose = false;
49
41
  let noNpmDeps = false;
42
+ let noBuild = false;
50
43
 
51
44
  for (let index = 0; index < args.length; index += 1) {
52
45
  const arg = args[index];
@@ -70,6 +63,10 @@ export function parseDistArgs(args: string[]): DistArgs {
70
63
  noNpmDeps = true;
71
64
  continue;
72
65
  }
66
+ if (arg === "--no-build") {
67
+ noBuild = true;
68
+ continue;
69
+ }
73
70
  if (arg === "-V" || arg === "--verbose") {
74
71
  verbose = true;
75
72
  continue;
@@ -87,105 +84,34 @@ export function parseDistArgs(args: string[]): DistArgs {
87
84
  outDir: path.resolve(outDir),
88
85
  verbose,
89
86
  noNpmDeps,
87
+ noBuild,
90
88
  };
91
89
  }
92
90
 
93
- function readManifest(pluginDir: string): ManifestInfo {
94
- const manifestPath = path.join(pluginDir, "openvcs.plugin.json");
95
- if (!fs.existsSync(manifestPath) || !fs.statSync(manifestPath).isFile()) {
96
- throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
97
- }
98
-
99
- let manifest: unknown;
100
- try {
101
- manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
102
- } catch (error: unknown) {
103
- const detail = error instanceof Error ? error.message : String(error);
104
- throw new Error(`parse ${manifestPath}: ${detail}`);
105
- }
106
-
107
- const pluginId =
108
- typeof (manifest as { id?: unknown }).id === "string"
109
- ? ((manifest as { id: string }).id.trim() as string)
110
- : "";
111
- if (!pluginId) {
112
- throw new Error(`manifest ${manifestPath} is missing a string 'id'`);
113
- }
114
- if (pluginId === "." || pluginId === ".." || pluginId.includes("/") || pluginId.includes("\\")) {
115
- throw new Error(`manifest id must not contain path separators: ${pluginId}`);
116
- }
117
-
118
- const moduleValue = (manifest as { module?: { exec?: unknown } }).module;
119
- const moduleExec = typeof moduleValue?.exec === "string" ? moduleValue.exec.trim() : undefined;
120
-
121
- return {
122
- pluginId,
123
- moduleExec,
124
- manifestPath,
125
- };
126
- }
127
-
128
- function validateDeclaredModuleExec(pluginDir: string, moduleExec: string | undefined): void {
129
- if (!moduleExec) {
130
- return;
131
- }
132
-
133
- const normalizedExec = moduleExec.trim();
134
- const lowered = normalizedExec.toLowerCase();
135
- if (!lowered.endsWith(".js") && !lowered.endsWith(".mjs") && !lowered.endsWith(".cjs")) {
136
- throw new Error(`manifest exec must end with .js/.mjs/.cjs (Node runtime): ${moduleExec}`);
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}`);
137
95
  }
138
- if (path.isAbsolute(normalizedExec)) {
139
- throw new Error(`manifest module.exec must be a relative path under bin/: ${moduleExec}`);
140
- }
141
-
142
- const binDir = path.resolve(pluginDir, "bin");
143
- const targetPath = path.resolve(binDir, normalizedExec);
144
- if (!isPathInside(binDir, targetPath) || targetPath === binDir) {
145
- 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}`);
146
100
  }
147
101
  if (!fs.existsSync(targetPath) || !fs.lstatSync(targetPath).isFile()) {
148
- throw new Error(`module entrypoint not found at ${targetPath}`);
102
+ throw new Error(`manifest entry file not found: ${entry}`);
149
103
  }
150
104
  }
151
105
 
152
- function hasPackageJson(pluginDir: string): boolean {
153
- const packageJsonPath = path.join(pluginDir, "package.json");
154
- return fs.existsSync(packageJsonPath) && fs.lstatSync(packageJsonPath).isFile();
155
- }
156
-
157
- function runCommand(program: string, args: string[], cwd: string, verbose: boolean): void {
158
- if (verbose) {
159
- process.stderr.write(`Running command in ${cwd}: ${program} ${args.join(" ")}\n`);
160
- }
161
-
162
- const result = spawnSync(program, args, {
163
- cwd,
164
- encoding: "utf8",
165
- stdio: ["ignore", "pipe", "pipe"],
166
- }) as CommandResult;
167
-
168
- if (result.error) {
169
- throw new Error(`failed to spawn '${program}' in ${cwd}: ${result.error.message}`);
170
- }
171
- if (result.status === 0) {
172
- if (verbose) {
173
- if (result.stdout?.trim()) {
174
- process.stderr.write(`${result.stdout.trim()}\n`);
175
- }
176
- if (result.stderr?.trim()) {
177
- process.stderr.write(`${result.stderr.trim()}\n`);
178
- }
179
- }
180
- return;
181
- }
182
-
183
- throw new Error(
184
- `command failed (${program} ${args.join(" ")}), exit code ${result.status}, stdout='${(result.stdout || "").trim()}', stderr='${(result.stderr || "").trim()}'`
185
- );
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);
186
112
  }
187
113
 
188
- function ensurePackageLock(pluginDir: string, verbose: boolean): void {
114
+ function ensurePackageLock(pluginDir: string, bundleDir: string, verbose: boolean): void {
189
115
  if (!hasPackageJson(pluginDir)) {
190
116
  return;
191
117
  }
@@ -194,13 +120,16 @@ function ensurePackageLock(pluginDir: string, verbose: boolean): void {
194
120
  return;
195
121
  }
196
122
 
123
+ const packageJsonPath = path.join(pluginDir, "package.json");
124
+ copyFileStrict(packageJsonPath, path.join(bundleDir, "package.json"));
125
+
197
126
  if (verbose) {
198
- process.stderr.write(`Generating package-lock.json in ${pluginDir}\n`);
127
+ process.stderr.write(`Generating package-lock.json in staging\n`);
199
128
  }
200
129
  runCommand(
201
130
  npmExecutable(),
202
131
  ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"],
203
- pluginDir,
132
+ bundleDir,
204
133
  verbose
205
134
  );
206
135
  }
@@ -212,12 +141,17 @@ function copyNpmFilesToStaging(pluginDir: string, bundleDir: string): void {
212
141
  if (!fs.existsSync(packageJsonPath) || !fs.lstatSync(packageJsonPath).isFile()) {
213
142
  throw new Error(`missing package.json at ${packageJsonPath}`);
214
143
  }
215
- if (!fs.existsSync(lockPath) || !fs.lstatSync(lockPath).isFile()) {
216
- throw new Error(`missing package-lock.json at ${lockPath}`);
217
- }
218
144
 
219
145
  copyFileStrict(packageJsonPath, path.join(bundleDir, "package.json"));
220
- 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
+ }
221
155
  }
222
156
 
223
157
  function rejectNativeAddonsRecursive(dirPath: string): void {
@@ -261,14 +195,21 @@ function installNpmDependencies(pluginDir: string, bundleDir: string, verbose: b
261
195
  }
262
196
 
263
197
  function copyIcon(pluginDir: string, bundleDir: string): void {
264
- for (const extension of ICON_EXTENSIONS) {
265
- const fileName = `icon.${extension}`;
266
- const sourcePath = path.join(pluginDir, fileName);
267
- if (!fs.existsSync(sourcePath)) {
268
- 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;
269
212
  }
270
- copyFileStrict(sourcePath, path.join(bundleDir, fileName));
271
- return;
272
213
  }
273
214
  }
274
215
 
@@ -294,16 +235,21 @@ async function writeTarGz(outPath: string, baseDir: string, folderName: string):
294
235
  }
295
236
 
296
237
  export async function bundlePlugin(parsedArgs: DistArgs): Promise<string> {
297
- const { pluginDir, outDir, verbose, noNpmDeps } = parsedArgs;
238
+ const { pluginDir, outDir, verbose, noNpmDeps, noBuild } = parsedArgs;
298
239
  if (verbose) {
299
240
  process.stderr.write(`Bundling plugin from: ${pluginDir}\n`);
300
241
  }
301
242
 
302
- const { pluginId, moduleExec, manifestPath } = readManifest(pluginDir);
243
+ const { pluginId, moduleExec, entry, manifestPath } = noBuild
244
+ ? readManifest(pluginDir)
245
+ : buildPluginAssets({ pluginDir, verbose });
303
246
  const themesPath = path.join(pluginDir, "themes");
304
247
  const hasThemes = fs.existsSync(themesPath) && fs.lstatSync(themesPath).isDirectory();
305
- if (!moduleExec && !hasThemes) {
306
- 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/");
307
253
  }
308
254
  validateDeclaredModuleExec(pluginDir, moduleExec);
309
255
 
@@ -317,6 +263,10 @@ export async function bundlePlugin(parsedArgs: DistArgs): Promise<string> {
317
263
  copyFileStrict(manifestPath, path.join(bundleDir, "openvcs.plugin.json"));
318
264
  copyIcon(pluginDir, bundleDir);
319
265
 
266
+ if (entry) {
267
+ copyEntryDirectory(pluginDir, bundleDir, entry);
268
+ }
269
+
320
270
  const sourceBinDir = path.join(pluginDir, "bin");
321
271
  if (fs.existsSync(sourceBinDir) && fs.lstatSync(sourceBinDir).isDirectory()) {
322
272
  copyDirectoryRecursiveStrict(sourceBinDir, path.join(bundleDir, "bin"));
@@ -326,7 +276,7 @@ export async function bundlePlugin(parsedArgs: DistArgs): Promise<string> {
326
276
  }
327
277
 
328
278
  if (!noNpmDeps && hasPackageJson(pluginDir)) {
329
- ensurePackageLock(pluginDir, verbose);
279
+ ensurePackageLock(pluginDir, bundleDir, verbose);
330
280
  installNpmDependencies(pluginDir, bundleDir, verbose);
331
281
  }
332
282
 
@@ -349,5 +299,6 @@ export const __private = {
349
299
  rejectNativeAddonsRecursive,
350
300
  uniqueStagingDir,
351
301
  validateDeclaredModuleExec,
302
+ validateManifestEntry,
352
303
  writeTarGz,
353
304
  };
package/src/lib/init.ts CHANGED
@@ -23,6 +23,12 @@ interface CollectAnswersOptions {
23
23
  targetHint?: string;
24
24
  }
25
25
 
26
+ interface PromptDriver {
27
+ promptText(label: string, defaultValue?: string): Promise<string>;
28
+ promptBoolean(label: string, defaultValue: boolean): Promise<boolean>;
29
+ close(): void;
30
+ }
31
+
26
32
  interface InitCommandError {
27
33
  code?: string;
28
34
  }
@@ -68,37 +74,49 @@ function defaultPluginNameFromId(pluginId: string): string {
68
74
  return words.length > 0 ? words.join(" ") : "OpenVCS Plugin";
69
75
  }
70
76
 
71
- async function promptText(
72
- rl: readline.Interface,
73
- label: string,
74
- defaultValue = ""
75
- ): Promise<string> {
76
- const suffix = defaultValue ? ` [${defaultValue}]` : "";
77
- const answer = await rl.question(`${label}${suffix}: `);
78
- const trimmed = answer.trim();
79
- return trimmed || defaultValue;
77
+ function validatePluginId(pluginId: string): string | undefined {
78
+ if (!pluginId) {
79
+ return "Plugin id is required.";
80
+ }
81
+ if (pluginId === "." || pluginId === "..") {
82
+ return "Plugin id must not be '.' or '..'.";
83
+ }
84
+ if (pluginId.includes("/") || pluginId.includes("\\")) {
85
+ return "Plugin id must not contain path separators (/ or \\).";
86
+ }
87
+ return undefined;
80
88
  }
81
89
 
82
- async function promptBoolean(
83
- rl: readline.Interface,
84
- label: string,
85
- defaultValue: boolean
86
- ): Promise<boolean> {
87
- const suffix = defaultValue ? "Y/n" : "y/N";
88
- while (true) {
89
- const answer = await rl.question(`${label} (${suffix}): `);
90
- const normalized = answer.trim().toLowerCase();
91
- if (!normalized) {
92
- return defaultValue;
93
- }
94
- if (normalized === "y" || normalized === "yes") {
95
- return true;
96
- }
97
- if (normalized === "n" || normalized === "no") {
98
- return false;
99
- }
100
- process.stderr.write("Please answer yes or no.\n");
101
- }
90
+ function createReadlinePromptDriver(output: NodeJS.WritableStream = process.stderr): PromptDriver {
91
+ const rl = readline.createInterface({ input: stdin, output: stdout });
92
+ return {
93
+ async promptText(label: string, defaultValue = ""): Promise<string> {
94
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
95
+ const answer = await rl.question(`${label}${suffix}: `);
96
+ const trimmed = answer.trim();
97
+ return trimmed || defaultValue;
98
+ },
99
+ async promptBoolean(label: string, defaultValue: boolean): Promise<boolean> {
100
+ const suffix = defaultValue ? "Y/n" : "y/N";
101
+ while (true) {
102
+ const answer = await rl.question(`${label} (${suffix}): `);
103
+ const normalized = answer.trim().toLowerCase();
104
+ if (!normalized) {
105
+ return defaultValue;
106
+ }
107
+ if (normalized === "y" || normalized === "yes") {
108
+ return true;
109
+ }
110
+ if (normalized === "n" || normalized === "no") {
111
+ return false;
112
+ }
113
+ output.write("Please answer yes or no.\n");
114
+ }
115
+ },
116
+ close(): void {
117
+ rl.close();
118
+ },
119
+ };
102
120
  }
103
121
 
104
122
  function writeJson(filePath: string, value: unknown): void {
@@ -116,11 +134,14 @@ function directoryHasEntries(targetDir: string): boolean {
116
134
  return entries.length > 0;
117
135
  }
118
136
 
119
- async function collectAnswers({ forceTheme, targetHint }: CollectAnswersOptions): Promise<InitAnswers> {
137
+ async function collectAnswers(
138
+ { forceTheme, targetHint }: CollectAnswersOptions,
139
+ promptDriver: PromptDriver = createReadlinePromptDriver(),
140
+ output: NodeJS.WritableStream = process.stderr
141
+ ): Promise<InitAnswers> {
120
142
  const defaultTarget = targetHint || path.join(process.cwd(), "openvcs-plugin");
121
- const rl = readline.createInterface({ input: stdin, output: stdout });
122
143
  try {
123
- const targetText = await promptText(rl, "Target directory", defaultTarget);
144
+ const targetText = await promptDriver.promptText("Target directory", defaultTarget);
124
145
  const targetDir = path.resolve(targetText);
125
146
 
126
147
  let kind: "module" | "theme" = "module";
@@ -128,7 +149,7 @@ async function collectAnswers({ forceTheme, targetHint }: CollectAnswersOptions)
128
149
  kind = "theme";
129
150
  } else {
130
151
  while (true) {
131
- const value = (await promptText(rl, "Template type (module/theme)", "module"))
152
+ const value = (await promptDriver.promptText("Template type (module/theme)", "module"))
132
153
  .trim()
133
154
  .toLowerCase();
134
155
  if (value === "module" || value === "m") {
@@ -139,29 +160,35 @@ async function collectAnswers({ forceTheme, targetHint }: CollectAnswersOptions)
139
160
  kind = "theme";
140
161
  break;
141
162
  }
142
- process.stderr.write("Please choose 'module' or 'theme'.\n");
163
+ output.write("Please choose 'module' or 'theme'.\n");
143
164
  }
144
165
  }
145
166
 
146
167
  const defaultId = defaultPluginIdFromDir(targetDir);
147
- let pluginId = "";
168
+ let pluginId: string | undefined;
148
169
  while (!pluginId) {
149
- pluginId = (await promptText(rl, "Plugin id", defaultId)).trim();
170
+ const candidateId = (await promptDriver.promptText("Plugin id", defaultId)).trim();
171
+ const validationError = validatePluginId(candidateId);
172
+ if (!validationError) {
173
+ pluginId = candidateId;
174
+ break;
175
+ }
176
+ output.write(`${validationError}\n`);
150
177
  }
151
178
 
152
179
  const defaultName = defaultPluginNameFromId(pluginId);
153
180
  let pluginName = "";
154
181
  while (!pluginName) {
155
- pluginName = (await promptText(rl, "Plugin name", defaultName)).trim();
182
+ pluginName = (await promptDriver.promptText("Plugin name", defaultName)).trim();
156
183
  }
157
184
 
158
185
  let pluginVersion = "";
159
186
  while (!pluginVersion) {
160
- pluginVersion = (await promptText(rl, "Version", "0.1.0")).trim();
187
+ pluginVersion = (await promptDriver.promptText("Version", "0.1.0")).trim();
161
188
  }
162
189
 
163
- const defaultEnabled = await promptBoolean(rl, "Default enabled", true);
164
- const runNpmInstall = await promptBoolean(rl, "Run npm install now", true);
190
+ const defaultEnabled = await promptDriver.promptBoolean("Default enabled", true);
191
+ const runNpmInstall = await promptDriver.promptBoolean("Run npm install now", true);
165
192
 
166
193
  return {
167
194
  targetDir,
@@ -173,7 +200,7 @@ async function collectAnswers({ forceTheme, targetHint }: CollectAnswersOptions)
173
200
  runNpmInstall,
174
201
  };
175
202
  } finally {
176
- rl.close();
203
+ promptDriver.close();
177
204
  }
178
205
  }
179
206
 
@@ -209,9 +236,10 @@ function writeModuleTemplate(answers: InitAnswers): void {
209
236
  private: true,
210
237
  type: "module",
211
238
  scripts: {
212
- "build:ts": "tsc -p tsconfig.json",
213
- build: "npm run build:ts && openvcs dist --plugin-dir . --out dist",
214
- 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",
215
243
  },
216
244
  devDependencies: {
217
245
  "@openvcs/sdk": `^${packageJson.version}`,
@@ -245,8 +273,9 @@ function writeThemeTemplate(answers: InitAnswers): void {
245
273
  version: answers.pluginVersion,
246
274
  private: true,
247
275
  scripts: {
248
- build: "openvcs dist --plugin-dir . --out dist",
249
- test: "openvcs dist --plugin-dir . --out dist --no-npm-deps",
276
+ build: "openvcs build",
277
+ dist: "openvcs dist --plugin-dir . --out dist",
278
+ test: "openvcs dist --plugin-dir . --out dist --no-build --no-npm-deps",
250
279
  },
251
280
  devDependencies: {
252
281
  "@openvcs/sdk": `^${packageJson.version}`,
@@ -292,10 +321,9 @@ export async function runInitCommand(args: string[]): Promise<string> {
292
321
  } else if (!fs.lstatSync(answers.targetDir).isDirectory()) {
293
322
  throw new Error(`target path exists but is not a directory: ${answers.targetDir}`);
294
323
  } else if (directoryHasEntries(answers.targetDir)) {
295
- const rl = readline.createInterface({ input: stdin, output: stdout });
324
+ const promptDriver = createReadlinePromptDriver();
296
325
  try {
297
- const proceed = await promptBoolean(
298
- rl,
326
+ const proceed = await promptDriver.promptBoolean(
299
327
  `Directory ${answers.targetDir} is not empty. Continue and overwrite known files`,
300
328
  false
301
329
  );
@@ -303,7 +331,7 @@ export async function runInitCommand(args: string[]): Promise<string> {
303
331
  throw new Error("aborted by user");
304
332
  }
305
333
  } finally {
306
- rl.close();
334
+ promptDriver.close();
307
335
  }
308
336
  }
309
337
 
@@ -328,3 +356,11 @@ export function isUsageError(error: unknown): error is InitCommandError {
328
356
  typeof (error as InitCommandError).code === "string"
329
357
  );
330
358
  }
359
+
360
+ export const __private = {
361
+ collectAnswers,
362
+ createReadlinePromptDriver,
363
+ defaultPluginIdFromDir,
364
+ sanitizeIdToken,
365
+ validatePluginId,
366
+ };
@@ -0,0 +1,95 @@
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 { buildPluginAssets, parseBuildArgs, readManifest, validateDeclaredModuleExec } = require("../lib/build");
7
+ const { cleanupTempDir, makeTempDir, writeJson, writeText } = require("./helpers");
8
+
9
+ test("parseBuildArgs uses defaults", () => {
10
+ const parsed = parseBuildArgs([]);
11
+ assert.equal(parsed.pluginDir, process.cwd());
12
+ assert.equal(parsed.verbose, false);
13
+ });
14
+
15
+ test("parseBuildArgs parses known flags", () => {
16
+ const parsed = parseBuildArgs(["--plugin-dir", "some/plugin", "--verbose"]);
17
+ assert.equal(parsed.pluginDir, path.resolve("some/plugin"));
18
+ assert.equal(parsed.verbose, true);
19
+ });
20
+
21
+ test("parseBuildArgs help returns usage error", () => {
22
+ assert.throws(() => parseBuildArgs(["--help"]), /openvcs build \[args\]/);
23
+ });
24
+
25
+ test("buildPluginAssets no-ops for theme-only plugins", () => {
26
+ const root = makeTempDir("openvcs-sdk-test");
27
+ const pluginDir = path.join(root, "plugin");
28
+
29
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), { id: "theme-only" });
30
+ const manifest = buildPluginAssets({ pluginDir, verbose: false });
31
+
32
+ assert.equal(manifest.pluginId, "theme-only");
33
+ cleanupTempDir(root);
34
+ });
35
+
36
+ test("buildPluginAssets requires package.json for code plugins", () => {
37
+ const root = makeTempDir("openvcs-sdk-test");
38
+ const pluginDir = path.join(root, "plugin");
39
+
40
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
41
+ id: "missing-package",
42
+ module: { exec: "plugin.js" },
43
+ });
44
+
45
+ assert.throws(
46
+ () => buildPluginAssets({ pluginDir, verbose: false }),
47
+ /code plugins must include package\.json/
48
+ );
49
+
50
+ cleanupTempDir(root);
51
+ });
52
+
53
+ test("buildPluginAssets runs build:plugin and validates output", () => {
54
+ const root = makeTempDir("openvcs-sdk-test");
55
+ const pluginDir = path.join(root, "plugin");
56
+
57
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
58
+ id: "builder",
59
+ module: { exec: "plugin.js" },
60
+ });
61
+ writeJson(path.join(pluginDir, "package.json"), {
62
+ name: "builder",
63
+ private: true,
64
+ scripts: {
65
+ "build:plugin": "node ./scripts/build-plugin.js",
66
+ },
67
+ });
68
+ writeText(
69
+ path.join(pluginDir, "scripts", "build-plugin.js"),
70
+ "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"
71
+ );
72
+
73
+ const manifest = buildPluginAssets({ pluginDir, verbose: false });
74
+
75
+ assert.equal(manifest.pluginId, "builder");
76
+ assert.equal(fs.existsSync(path.join(pluginDir, "bin", "plugin.js")), true);
77
+ cleanupTempDir(root);
78
+ });
79
+
80
+ test("readManifest and validateDeclaredModuleExec stay reusable", () => {
81
+ const root = makeTempDir("openvcs-sdk-test");
82
+ const pluginDir = path.join(root, "plugin");
83
+
84
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
85
+ id: "reusable",
86
+ module: { exec: "plugin.js" },
87
+ });
88
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
89
+
90
+ const manifest = readManifest(pluginDir);
91
+ assert.equal(manifest.moduleExec, "plugin.js");
92
+ assert.doesNotThrow(() => validateDeclaredModuleExec(pluginDir, manifest.moduleExec));
93
+
94
+ cleanupTempDir(root);
95
+ });