@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,364 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as readline from "node:readline/promises";
4
+ import { stdin, stdout } from "node:process";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ const packageJson: { version: string } = require("../package.json");
8
+
9
+ type UsageError = Error & { code?: string };
10
+
11
+ interface InitAnswers {
12
+ targetDir: string;
13
+ kind: "module" | "theme";
14
+ pluginId: string;
15
+ pluginName: string;
16
+ pluginVersion: string;
17
+ defaultEnabled: boolean;
18
+ runNpmInstall: boolean;
19
+ }
20
+
21
+ interface CollectAnswersOptions {
22
+ forceTheme: boolean;
23
+ targetHint?: string;
24
+ }
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
+
32
+ interface InitCommandError {
33
+ code?: string;
34
+ }
35
+
36
+ function npmExecutable(): string {
37
+ return process.platform === "win32" ? "npm.cmd" : "npm";
38
+ }
39
+
40
+ export function initUsage(commandName = "openvcs"): string {
41
+ return `Usage: ${commandName} init [--theme] [target-dir]\n\nOptions:\n --theme Start with a theme-only plugin template\n`;
42
+ }
43
+
44
+ function sanitizeIdToken(raw: string): string {
45
+ let output = "";
46
+ let lastWasSeparator = false;
47
+ for (const char of raw) {
48
+ const isValid = /[a-zA-Z0-9._-]/.test(char);
49
+ if (isValid) {
50
+ output += char.toLowerCase();
51
+ lastWasSeparator = false;
52
+ continue;
53
+ }
54
+ if (!lastWasSeparator) {
55
+ output += "-";
56
+ lastWasSeparator = true;
57
+ }
58
+ }
59
+ return output.replace(/^-+|-+$/g, "");
60
+ }
61
+
62
+ function defaultPluginIdFromDir(targetDir: string): string {
63
+ const name = path.basename(targetDir) || "openvcs-plugin";
64
+ const token = sanitizeIdToken(name);
65
+ return token || "openvcs.plugin";
66
+ }
67
+
68
+ function defaultPluginNameFromId(pluginId: string): string {
69
+ const words = pluginId
70
+ .split(/[._-]+/)
71
+ .map((word) => word.trim())
72
+ .filter(Boolean)
73
+ .map((word) => word[0].toUpperCase() + word.slice(1));
74
+ return words.length > 0 ? words.join(" ") : "OpenVCS Plugin";
75
+ }
76
+
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;
88
+ }
89
+
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
+ };
120
+ }
121
+
122
+ function writeJson(filePath: string, value: unknown): void {
123
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
124
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
125
+ }
126
+
127
+ function writeText(filePath: string, content: string): void {
128
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
129
+ fs.writeFileSync(filePath, content, "utf8");
130
+ }
131
+
132
+ function directoryHasEntries(targetDir: string): boolean {
133
+ const entries = fs.readdirSync(targetDir);
134
+ return entries.length > 0;
135
+ }
136
+
137
+ async function collectAnswers(
138
+ { forceTheme, targetHint }: CollectAnswersOptions,
139
+ promptDriver: PromptDriver = createReadlinePromptDriver(),
140
+ output: NodeJS.WritableStream = process.stderr
141
+ ): Promise<InitAnswers> {
142
+ const defaultTarget = targetHint || path.join(process.cwd(), "openvcs-plugin");
143
+ try {
144
+ const targetText = await promptDriver.promptText("Target directory", defaultTarget);
145
+ const targetDir = path.resolve(targetText);
146
+
147
+ let kind: "module" | "theme" = "module";
148
+ if (forceTheme) {
149
+ kind = "theme";
150
+ } else {
151
+ while (true) {
152
+ const value = (await promptDriver.promptText("Template type (module/theme)", "module"))
153
+ .trim()
154
+ .toLowerCase();
155
+ if (value === "module" || value === "m") {
156
+ kind = "module";
157
+ break;
158
+ }
159
+ if (value === "theme" || value === "t") {
160
+ kind = "theme";
161
+ break;
162
+ }
163
+ output.write("Please choose 'module' or 'theme'.\n");
164
+ }
165
+ }
166
+
167
+ const defaultId = defaultPluginIdFromDir(targetDir);
168
+ let pluginId: string | undefined;
169
+ while (!pluginId) {
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`);
177
+ }
178
+
179
+ const defaultName = defaultPluginNameFromId(pluginId);
180
+ let pluginName = "";
181
+ while (!pluginName) {
182
+ pluginName = (await promptDriver.promptText("Plugin name", defaultName)).trim();
183
+ }
184
+
185
+ let pluginVersion = "";
186
+ while (!pluginVersion) {
187
+ pluginVersion = (await promptDriver.promptText("Version", "0.1.0")).trim();
188
+ }
189
+
190
+ const defaultEnabled = await promptDriver.promptBoolean("Default enabled", true);
191
+ const runNpmInstall = await promptDriver.promptBoolean("Run npm install now", true);
192
+
193
+ return {
194
+ targetDir,
195
+ kind,
196
+ pluginId,
197
+ pluginName,
198
+ pluginVersion,
199
+ defaultEnabled,
200
+ runNpmInstall,
201
+ };
202
+ } finally {
203
+ promptDriver.close();
204
+ }
205
+ }
206
+
207
+ function runNpmInstall(targetDir: string): void {
208
+ const result = spawnSync(npmExecutable(), ["install"], {
209
+ cwd: targetDir,
210
+ stdio: "inherit",
211
+ });
212
+ if (result.error) {
213
+ throw new Error(`failed to spawn npm install in ${targetDir}: ${result.error.message}`);
214
+ }
215
+ if (result.status !== 0) {
216
+ throw new Error(`npm install failed in ${targetDir} (code ${result.status})`);
217
+ }
218
+ }
219
+
220
+ function writeCommonFiles(answers: InitAnswers): void {
221
+ writeJson(path.join(answers.targetDir, "openvcs.plugin.json"), {
222
+ id: answers.pluginId,
223
+ name: answers.pluginName,
224
+ version: answers.pluginVersion,
225
+ default_enabled: answers.defaultEnabled,
226
+ ...(answers.kind === "module" ? { module: { exec: "plugin.js" } } : {}),
227
+ });
228
+ writeText(path.join(answers.targetDir, ".gitignore"), "node_modules/\ndist/\n");
229
+ }
230
+
231
+ function writeModuleTemplate(answers: InitAnswers): void {
232
+ writeCommonFiles(answers);
233
+ writeJson(path.join(answers.targetDir, "package.json"), {
234
+ name: answers.pluginId,
235
+ version: answers.pluginVersion,
236
+ private: true,
237
+ type: "module",
238
+ scripts: {
239
+ "build:ts": "tsc -p tsconfig.json",
240
+ build: "npm run build:ts && openvcs dist --plugin-dir . --out dist",
241
+ test: "openvcs dist --plugin-dir . --out dist --no-npm-deps",
242
+ },
243
+ devDependencies: {
244
+ "@openvcs/sdk": `^${packageJson.version}`,
245
+ "@types/node": "^22.0.0",
246
+ typescript: "^5.8.2",
247
+ },
248
+ });
249
+ writeJson(path.join(answers.targetDir, "tsconfig.json"), {
250
+ compilerOptions: {
251
+ target: "ES2022",
252
+ module: "NodeNext",
253
+ moduleResolution: "NodeNext",
254
+ types: ["node"],
255
+ strict: true,
256
+ skipLibCheck: true,
257
+ outDir: "bin",
258
+ rootDir: "src",
259
+ },
260
+ include: ["src/**/*.ts"],
261
+ });
262
+ writeText(
263
+ path.join(answers.targetDir, "src", "plugin.ts"),
264
+ "const message = \"OpenVCS plugin started\";\nprocess.stderr.write(`${message}\\n`);\n"
265
+ );
266
+ }
267
+
268
+ function writeThemeTemplate(answers: InitAnswers): void {
269
+ writeCommonFiles(answers);
270
+ writeJson(path.join(answers.targetDir, "package.json"), {
271
+ name: answers.pluginId,
272
+ version: answers.pluginVersion,
273
+ private: true,
274
+ scripts: {
275
+ build: "openvcs dist --plugin-dir . --out dist",
276
+ test: "openvcs dist --plugin-dir . --out dist --no-npm-deps",
277
+ },
278
+ devDependencies: {
279
+ "@openvcs/sdk": `^${packageJson.version}`,
280
+ },
281
+ });
282
+ writeJson(path.join(answers.targetDir, "themes", "default", "theme.json"), {
283
+ name: answers.pluginName || "Default Theme",
284
+ description: "Starter OpenVCS theme generated by @openvcs/sdk",
285
+ tokens: {
286
+ accent: "#2a7fff",
287
+ background: "#0f172a",
288
+ foreground: "#e2e8f0",
289
+ },
290
+ });
291
+ }
292
+
293
+ export async function runInitCommand(args: string[]): Promise<string> {
294
+ let forceTheme = false;
295
+ let targetHint: string | undefined;
296
+
297
+ for (const arg of args) {
298
+ if (arg === "--theme") {
299
+ forceTheme = true;
300
+ continue;
301
+ }
302
+ if (arg === "--help") {
303
+ const error = new Error(initUsage()) as UsageError;
304
+ error.code = "USAGE";
305
+ throw error;
306
+ }
307
+ if (arg.startsWith("-")) {
308
+ throw new Error(`unknown argument for init: ${arg}`);
309
+ }
310
+ if (targetHint) {
311
+ throw new Error("init accepts at most one target directory");
312
+ }
313
+ targetHint = arg;
314
+ }
315
+
316
+ const answers = await collectAnswers({ forceTheme, targetHint });
317
+ if (!fs.existsSync(answers.targetDir)) {
318
+ fs.mkdirSync(answers.targetDir, { recursive: true });
319
+ } else if (!fs.lstatSync(answers.targetDir).isDirectory()) {
320
+ throw new Error(`target path exists but is not a directory: ${answers.targetDir}`);
321
+ } else if (directoryHasEntries(answers.targetDir)) {
322
+ const promptDriver = createReadlinePromptDriver();
323
+ try {
324
+ const proceed = await promptDriver.promptBoolean(
325
+ `Directory ${answers.targetDir} is not empty. Continue and overwrite known files`,
326
+ false
327
+ );
328
+ if (!proceed) {
329
+ throw new Error("aborted by user");
330
+ }
331
+ } finally {
332
+ promptDriver.close();
333
+ }
334
+ }
335
+
336
+ if (answers.kind === "module") {
337
+ writeModuleTemplate(answers);
338
+ } else {
339
+ writeThemeTemplate(answers);
340
+ }
341
+
342
+ if (answers.runNpmInstall) {
343
+ runNpmInstall(answers.targetDir);
344
+ }
345
+
346
+ return answers.targetDir;
347
+ }
348
+
349
+ export function isUsageError(error: unknown): error is InitCommandError {
350
+ return (
351
+ typeof error === "object" &&
352
+ error !== null &&
353
+ "code" in error &&
354
+ typeof (error as InitCommandError).code === "string"
355
+ );
356
+ }
357
+
358
+ export const __private = {
359
+ collectAnswers,
360
+ createReadlinePromptDriver,
361
+ defaultPluginIdFromDir,
362
+ sanitizeIdToken,
363
+ validatePluginId,
364
+ };
@@ -0,0 +1,87 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const { spawnSync } = require("node:child_process");
5
+ const test = require("node:test");
6
+
7
+ const packageJson = require("../package.json");
8
+ const { cleanupTempDir, makeTempDir, writeJson, writeText } = require("./helpers");
9
+
10
+ const cliPath = path.join(__dirname, "..", "bin", "openvcs.js");
11
+
12
+ function runCli(args, cwd = process.cwd(), input = "") {
13
+ return spawnSync(process.execPath, [cliPath, ...args], {
14
+ cwd,
15
+ input,
16
+ encoding: "utf8",
17
+ });
18
+ }
19
+
20
+ test("openvcs --version prints package version", () => {
21
+ const result = runCli(["--version"]);
22
+ assert.equal(result.status, 0);
23
+ assert.equal(result.stdout.trim(), `openvcs ${packageJson.version}`);
24
+ });
25
+
26
+ test("openvcs --help prints usage", () => {
27
+ const result = runCli(["--help"]);
28
+ assert.equal(result.status, 0);
29
+ assert.match(result.stdout, /Usage: openvcs <command>/);
30
+ });
31
+
32
+ test("openvcs with no args exits non-zero", () => {
33
+ const result = runCli([]);
34
+ assert.equal(result.status, 1);
35
+ assert.match(result.stderr, /Usage: openvcs <command>/);
36
+ });
37
+
38
+ test("openvcs dist --help prints dist usage", () => {
39
+ const result = runCli(["dist", "--help"]);
40
+ assert.equal(result.status, 0);
41
+ assert.match(result.stdout, /openvcs dist \[args\]/);
42
+ });
43
+
44
+ test("openvcs init --help prints init usage", () => {
45
+ const result = runCli(["init", "--help"]);
46
+ assert.equal(result.status, 0);
47
+ assert.match(result.stdout, /Usage: openvcs init/);
48
+ });
49
+
50
+ test("openvcs rejects unknown command", () => {
51
+ const result = runCli(["unknown"]);
52
+ assert.equal(result.status, 1);
53
+ assert.match(result.stderr, /unknown command: unknown/);
54
+ });
55
+
56
+ test("openvcs dist command creates bundle", () => {
57
+ const root = makeTempDir("openvcs-sdk-test");
58
+ const pluginDir = path.join(root, "plugin");
59
+ const outDir = path.join(root, "out");
60
+
61
+ writeJson(path.join(pluginDir, "openvcs.plugin.json"), {
62
+ id: "cli-plugin",
63
+ module: { exec: "plugin.js" },
64
+ });
65
+ writeText(path.join(pluginDir, "bin", "plugin.js"), "export {};\n");
66
+
67
+ const result = runCli([
68
+ "dist",
69
+ "--plugin-dir",
70
+ pluginDir,
71
+ "--out",
72
+ outDir,
73
+ "--no-npm-deps",
74
+ ]);
75
+
76
+ assert.equal(result.status, 0);
77
+ assert.match(result.stdout.trim(), /cli-plugin\.ovcsp$/);
78
+ assert.equal(fs.existsSync(path.join(outDir, "cli-plugin.ovcsp")), true);
79
+
80
+ cleanupTempDir(root);
81
+ });
82
+
83
+ test("openvcs dist reports argument errors", () => {
84
+ const result = runCli(["dist", "--plugin-dir"]);
85
+ assert.equal(result.status, 1);
86
+ assert.match(result.stderr, /missing value for --plugin-dir/);
87
+ });