@openvcs/sdk 0.2.0 → 0.2.1

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,353 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import tar = require("tar");
5
+ import {
6
+ copyDirectoryRecursiveStrict,
7
+ copyFileStrict,
8
+ ensureDirectory,
9
+ isPathInside,
10
+ rejectSymlinksRecursive,
11
+ } from "./fs-utils";
12
+
13
+ const ICON_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "avif", "svg"];
14
+
15
+ type UsageError = Error & { code?: string };
16
+
17
+ interface DistArgs {
18
+ pluginDir: string;
19
+ outDir: string;
20
+ verbose: boolean;
21
+ 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";
39
+ }
40
+
41
+ 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`;
43
+ }
44
+
45
+ export function parseDistArgs(args: string[]): DistArgs {
46
+ let pluginDir = process.cwd();
47
+ let outDir = "dist";
48
+ let verbose = false;
49
+ let noNpmDeps = false;
50
+
51
+ for (let index = 0; index < args.length; index += 1) {
52
+ const arg = args[index];
53
+ if (arg === "--plugin-dir") {
54
+ index += 1;
55
+ if (index >= args.length) {
56
+ throw new Error("missing value for --plugin-dir");
57
+ }
58
+ pluginDir = args[index] as string;
59
+ continue;
60
+ }
61
+ if (arg === "--out") {
62
+ index += 1;
63
+ if (index >= args.length) {
64
+ throw new Error("missing value for --out");
65
+ }
66
+ outDir = args[index] as string;
67
+ continue;
68
+ }
69
+ if (arg === "--no-npm-deps") {
70
+ noNpmDeps = true;
71
+ continue;
72
+ }
73
+ if (arg === "-V" || arg === "--verbose") {
74
+ verbose = true;
75
+ continue;
76
+ }
77
+ if (arg === "--help") {
78
+ const error = new Error(distUsage()) as UsageError;
79
+ error.code = "USAGE";
80
+ throw error;
81
+ }
82
+ throw new Error(`unknown flag: ${arg}`);
83
+ }
84
+
85
+ return {
86
+ pluginDir: path.resolve(pluginDir),
87
+ outDir: path.resolve(outDir),
88
+ verbose,
89
+ noNpmDeps,
90
+ };
91
+ }
92
+
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}`);
137
+ }
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}`);
146
+ }
147
+ if (!fs.existsSync(targetPath) || !fs.lstatSync(targetPath).isFile()) {
148
+ throw new Error(`module entrypoint not found at ${targetPath}`);
149
+ }
150
+ }
151
+
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
+ );
186
+ }
187
+
188
+ function ensurePackageLock(pluginDir: string, verbose: boolean): void {
189
+ if (!hasPackageJson(pluginDir)) {
190
+ return;
191
+ }
192
+ const lockPath = path.join(pluginDir, "package-lock.json");
193
+ if (fs.existsSync(lockPath) && fs.lstatSync(lockPath).isFile()) {
194
+ return;
195
+ }
196
+
197
+ if (verbose) {
198
+ process.stderr.write(`Generating package-lock.json in ${pluginDir}\n`);
199
+ }
200
+ runCommand(
201
+ npmExecutable(),
202
+ ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"],
203
+ pluginDir,
204
+ verbose
205
+ );
206
+ }
207
+
208
+ function copyNpmFilesToStaging(pluginDir: string, bundleDir: string): void {
209
+ const packageJsonPath = path.join(pluginDir, "package.json");
210
+ const lockPath = path.join(pluginDir, "package-lock.json");
211
+
212
+ if (!fs.existsSync(packageJsonPath) || !fs.lstatSync(packageJsonPath).isFile()) {
213
+ throw new Error(`missing package.json at ${packageJsonPath}`);
214
+ }
215
+ if (!fs.existsSync(lockPath) || !fs.lstatSync(lockPath).isFile()) {
216
+ throw new Error(`missing package-lock.json at ${lockPath}`);
217
+ }
218
+
219
+ copyFileStrict(packageJsonPath, path.join(bundleDir, "package.json"));
220
+ copyFileStrict(lockPath, path.join(bundleDir, "package-lock.json"));
221
+ }
222
+
223
+ function rejectNativeAddonsRecursive(dirPath: string): void {
224
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
225
+ for (const entry of entries) {
226
+ const entryPath = path.join(dirPath, entry.name);
227
+ const stats = fs.lstatSync(entryPath);
228
+ if (stats.isSymbolicLink()) {
229
+ throw new Error(`plugin contains a symlink: ${entryPath}`);
230
+ }
231
+ if (stats.isDirectory()) {
232
+ rejectNativeAddonsRecursive(entryPath);
233
+ continue;
234
+ }
235
+ if (!stats.isFile()) {
236
+ continue;
237
+ }
238
+ if (entry.name.toLowerCase().endsWith(".node")) {
239
+ throw new Error(`native Node addon files are not supported in portable bundles: ${entryPath}`);
240
+ }
241
+ }
242
+ }
243
+
244
+ function installNpmDependencies(pluginDir: string, bundleDir: string, verbose: boolean): void {
245
+ copyNpmFilesToStaging(pluginDir, bundleDir);
246
+ runCommand(
247
+ npmExecutable(),
248
+ ["ci", "--omit=dev", "--ignore-scripts", "--no-bin-links", "--no-audit", "--no-fund"],
249
+ bundleDir,
250
+ verbose
251
+ );
252
+
253
+ const nodeModulesPath = path.join(bundleDir, "node_modules");
254
+ if (!fs.existsSync(nodeModulesPath)) {
255
+ return;
256
+ }
257
+ if (!fs.lstatSync(nodeModulesPath).isDirectory()) {
258
+ throw new Error(`npm install produced non-directory node_modules path: ${nodeModulesPath}`);
259
+ }
260
+ rejectNativeAddonsRecursive(nodeModulesPath);
261
+ }
262
+
263
+ 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;
269
+ }
270
+ copyFileStrict(sourcePath, path.join(bundleDir, fileName));
271
+ return;
272
+ }
273
+ }
274
+
275
+ function uniqueStagingDir(outDir: string): string {
276
+ return path.join(outDir, `.openvcs-plugin-staging-${Date.now()}-${process.pid}`);
277
+ }
278
+
279
+ async function writeTarGz(outPath: string, baseDir: string, folderName: string): Promise<void> {
280
+ const folderPath = path.join(baseDir, folderName);
281
+ rejectSymlinksRecursive(folderPath);
282
+ await tar.create(
283
+ {
284
+ cwd: baseDir,
285
+ file: outPath,
286
+ gzip: true,
287
+ portable: true,
288
+ noMtime: true,
289
+ preservePaths: false,
290
+ strict: true,
291
+ },
292
+ [folderName]
293
+ );
294
+ }
295
+
296
+ export async function bundlePlugin(parsedArgs: DistArgs): Promise<string> {
297
+ const { pluginDir, outDir, verbose, noNpmDeps } = parsedArgs;
298
+ if (verbose) {
299
+ process.stderr.write(`Bundling plugin from: ${pluginDir}\n`);
300
+ }
301
+
302
+ const { pluginId, moduleExec, manifestPath } = readManifest(pluginDir);
303
+ const themesPath = path.join(pluginDir, "themes");
304
+ const hasThemes = fs.existsSync(themesPath) && fs.lstatSync(themesPath).isDirectory();
305
+ if (!moduleExec && !hasThemes) {
306
+ throw new Error("manifest has no module.exec or themes/");
307
+ }
308
+ validateDeclaredModuleExec(pluginDir, moduleExec);
309
+
310
+ ensureDirectory(outDir);
311
+ const stagingRoot = uniqueStagingDir(outDir);
312
+ const bundleDir = path.join(stagingRoot, pluginId);
313
+
314
+ ensureDirectory(bundleDir);
315
+
316
+ try {
317
+ copyFileStrict(manifestPath, path.join(bundleDir, "openvcs.plugin.json"));
318
+ copyIcon(pluginDir, bundleDir);
319
+
320
+ const sourceBinDir = path.join(pluginDir, "bin");
321
+ if (fs.existsSync(sourceBinDir) && fs.lstatSync(sourceBinDir).isDirectory()) {
322
+ copyDirectoryRecursiveStrict(sourceBinDir, path.join(bundleDir, "bin"));
323
+ }
324
+ if (hasThemes) {
325
+ copyDirectoryRecursiveStrict(themesPath, path.join(bundleDir, "themes"));
326
+ }
327
+
328
+ if (!noNpmDeps && hasPackageJson(pluginDir)) {
329
+ ensurePackageLock(pluginDir, verbose);
330
+ installNpmDependencies(pluginDir, bundleDir, verbose);
331
+ }
332
+
333
+ const outPath = path.join(outDir, `${pluginId}.ovcsp`);
334
+ if (fs.existsSync(outPath)) {
335
+ fs.rmSync(outPath, { force: true });
336
+ }
337
+
338
+ await writeTarGz(outPath, stagingRoot, pluginId);
339
+ return outPath;
340
+ } finally {
341
+ fs.rmSync(stagingRoot, { recursive: true, force: true });
342
+ }
343
+ }
344
+
345
+ export const __private = {
346
+ ICON_EXTENSIONS,
347
+ copyIcon,
348
+ readManifest,
349
+ rejectNativeAddonsRecursive,
350
+ uniqueStagingDir,
351
+ validateDeclaredModuleExec,
352
+ writeTarGz,
353
+ };
@@ -0,0 +1,78 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export function isPathInside(rootPath: string, candidatePath: string): boolean {
5
+ const relative = path.relative(rootPath, candidatePath);
6
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
7
+ }
8
+
9
+ export function rejectSymlinksRecursive(rootDir: string): void {
10
+ const stack = [rootDir];
11
+ while (stack.length > 0) {
12
+ const current = stack.pop();
13
+ if (!current) {
14
+ continue;
15
+ }
16
+
17
+ const entries = fs.readdirSync(current, { withFileTypes: true });
18
+ for (const entry of entries) {
19
+ const entryPath = path.join(current, entry.name);
20
+ const stats = fs.lstatSync(entryPath);
21
+ if (stats.isSymbolicLink()) {
22
+ throw new Error(`plugin contains a symlink: ${entryPath}`);
23
+ }
24
+ if (stats.isDirectory()) {
25
+ stack.push(entryPath);
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ export function ensureDirectory(filePath: string): void {
32
+ fs.mkdirSync(filePath, { recursive: true });
33
+ }
34
+
35
+ export function copyFileStrict(sourcePath: string, destinationPath: string): void {
36
+ const stats = fs.lstatSync(sourcePath);
37
+ if (stats.isSymbolicLink()) {
38
+ throw new Error(`plugin contains a symlink: ${sourcePath}`);
39
+ }
40
+ if (!stats.isFile()) {
41
+ throw new Error(`expected file: ${sourcePath}`);
42
+ }
43
+
44
+ ensureDirectory(path.dirname(destinationPath));
45
+ fs.copyFileSync(sourcePath, destinationPath);
46
+ }
47
+
48
+ export function copyDirectoryRecursiveStrict(sourceDir: string, destinationDir: string): void {
49
+ if (!fs.existsSync(sourceDir)) {
50
+ return;
51
+ }
52
+
53
+ const stats = fs.lstatSync(sourceDir);
54
+ if (stats.isSymbolicLink()) {
55
+ throw new Error(`plugin contains a symlink: ${sourceDir}`);
56
+ }
57
+ if (!stats.isDirectory()) {
58
+ throw new Error(`expected directory: ${sourceDir}`);
59
+ }
60
+
61
+ ensureDirectory(destinationDir);
62
+ const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
63
+ for (const entry of entries) {
64
+ const sourcePath = path.join(sourceDir, entry.name);
65
+ const destinationPath = path.join(destinationDir, entry.name);
66
+ const entryStats = fs.lstatSync(sourcePath);
67
+ if (entryStats.isSymbolicLink()) {
68
+ throw new Error(`plugin contains a symlink: ${sourcePath}`);
69
+ }
70
+ if (entryStats.isDirectory()) {
71
+ copyDirectoryRecursiveStrict(sourcePath, destinationPath);
72
+ continue;
73
+ }
74
+ if (entryStats.isFile()) {
75
+ fs.copyFileSync(sourcePath, destinationPath);
76
+ }
77
+ }
78
+ }