@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,330 @@
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 InitCommandError {
27
+ code?: string;
28
+ }
29
+
30
+ function npmExecutable(): string {
31
+ return process.platform === "win32" ? "npm.cmd" : "npm";
32
+ }
33
+
34
+ export function initUsage(commandName = "openvcs"): string {
35
+ return `Usage: ${commandName} init [--theme] [target-dir]\n\nOptions:\n --theme Start with a theme-only plugin template\n`;
36
+ }
37
+
38
+ function sanitizeIdToken(raw: string): string {
39
+ let output = "";
40
+ let lastWasSeparator = false;
41
+ for (const char of raw) {
42
+ const isValid = /[a-zA-Z0-9._-]/.test(char);
43
+ if (isValid) {
44
+ output += char.toLowerCase();
45
+ lastWasSeparator = false;
46
+ continue;
47
+ }
48
+ if (!lastWasSeparator) {
49
+ output += "-";
50
+ lastWasSeparator = true;
51
+ }
52
+ }
53
+ return output.replace(/^-+|-+$/g, "");
54
+ }
55
+
56
+ function defaultPluginIdFromDir(targetDir: string): string {
57
+ const name = path.basename(targetDir) || "openvcs-plugin";
58
+ const token = sanitizeIdToken(name);
59
+ return token || "openvcs.plugin";
60
+ }
61
+
62
+ function defaultPluginNameFromId(pluginId: string): string {
63
+ const words = pluginId
64
+ .split(/[._-]+/)
65
+ .map((word) => word.trim())
66
+ .filter(Boolean)
67
+ .map((word) => word[0].toUpperCase() + word.slice(1));
68
+ return words.length > 0 ? words.join(" ") : "OpenVCS Plugin";
69
+ }
70
+
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;
80
+ }
81
+
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
+ }
102
+ }
103
+
104
+ function writeJson(filePath: string, value: unknown): void {
105
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
106
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
107
+ }
108
+
109
+ function writeText(filePath: string, content: string): void {
110
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
111
+ fs.writeFileSync(filePath, content, "utf8");
112
+ }
113
+
114
+ function directoryHasEntries(targetDir: string): boolean {
115
+ const entries = fs.readdirSync(targetDir);
116
+ return entries.length > 0;
117
+ }
118
+
119
+ async function collectAnswers({ forceTheme, targetHint }: CollectAnswersOptions): Promise<InitAnswers> {
120
+ const defaultTarget = targetHint || path.join(process.cwd(), "openvcs-plugin");
121
+ const rl = readline.createInterface({ input: stdin, output: stdout });
122
+ try {
123
+ const targetText = await promptText(rl, "Target directory", defaultTarget);
124
+ const targetDir = path.resolve(targetText);
125
+
126
+ let kind: "module" | "theme" = "module";
127
+ if (forceTheme) {
128
+ kind = "theme";
129
+ } else {
130
+ while (true) {
131
+ const value = (await promptText(rl, "Template type (module/theme)", "module"))
132
+ .trim()
133
+ .toLowerCase();
134
+ if (value === "module" || value === "m") {
135
+ kind = "module";
136
+ break;
137
+ }
138
+ if (value === "theme" || value === "t") {
139
+ kind = "theme";
140
+ break;
141
+ }
142
+ process.stderr.write("Please choose 'module' or 'theme'.\n");
143
+ }
144
+ }
145
+
146
+ const defaultId = defaultPluginIdFromDir(targetDir);
147
+ let pluginId = "";
148
+ while (!pluginId) {
149
+ pluginId = (await promptText(rl, "Plugin id", defaultId)).trim();
150
+ }
151
+
152
+ const defaultName = defaultPluginNameFromId(pluginId);
153
+ let pluginName = "";
154
+ while (!pluginName) {
155
+ pluginName = (await promptText(rl, "Plugin name", defaultName)).trim();
156
+ }
157
+
158
+ let pluginVersion = "";
159
+ while (!pluginVersion) {
160
+ pluginVersion = (await promptText(rl, "Version", "0.1.0")).trim();
161
+ }
162
+
163
+ const defaultEnabled = await promptBoolean(rl, "Default enabled", true);
164
+ const runNpmInstall = await promptBoolean(rl, "Run npm install now", true);
165
+
166
+ return {
167
+ targetDir,
168
+ kind,
169
+ pluginId,
170
+ pluginName,
171
+ pluginVersion,
172
+ defaultEnabled,
173
+ runNpmInstall,
174
+ };
175
+ } finally {
176
+ rl.close();
177
+ }
178
+ }
179
+
180
+ function runNpmInstall(targetDir: string): void {
181
+ const result = spawnSync(npmExecutable(), ["install"], {
182
+ cwd: targetDir,
183
+ stdio: "inherit",
184
+ });
185
+ if (result.error) {
186
+ throw new Error(`failed to spawn npm install in ${targetDir}: ${result.error.message}`);
187
+ }
188
+ if (result.status !== 0) {
189
+ throw new Error(`npm install failed in ${targetDir} (code ${result.status})`);
190
+ }
191
+ }
192
+
193
+ function writeCommonFiles(answers: InitAnswers): void {
194
+ writeJson(path.join(answers.targetDir, "openvcs.plugin.json"), {
195
+ id: answers.pluginId,
196
+ name: answers.pluginName,
197
+ version: answers.pluginVersion,
198
+ default_enabled: answers.defaultEnabled,
199
+ ...(answers.kind === "module" ? { module: { exec: "plugin.js" } } : {}),
200
+ });
201
+ writeText(path.join(answers.targetDir, ".gitignore"), "node_modules/\ndist/\n");
202
+ }
203
+
204
+ function writeModuleTemplate(answers: InitAnswers): void {
205
+ writeCommonFiles(answers);
206
+ writeJson(path.join(answers.targetDir, "package.json"), {
207
+ name: answers.pluginId,
208
+ version: answers.pluginVersion,
209
+ private: true,
210
+ type: "module",
211
+ 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",
215
+ },
216
+ devDependencies: {
217
+ "@openvcs/sdk": `^${packageJson.version}`,
218
+ "@types/node": "^22.0.0",
219
+ typescript: "^5.8.2",
220
+ },
221
+ });
222
+ writeJson(path.join(answers.targetDir, "tsconfig.json"), {
223
+ compilerOptions: {
224
+ target: "ES2022",
225
+ module: "NodeNext",
226
+ moduleResolution: "NodeNext",
227
+ types: ["node"],
228
+ strict: true,
229
+ skipLibCheck: true,
230
+ outDir: "bin",
231
+ rootDir: "src",
232
+ },
233
+ include: ["src/**/*.ts"],
234
+ });
235
+ writeText(
236
+ path.join(answers.targetDir, "src", "plugin.ts"),
237
+ "const message = \"OpenVCS plugin started\";\nprocess.stderr.write(`${message}\\n`);\n"
238
+ );
239
+ }
240
+
241
+ function writeThemeTemplate(answers: InitAnswers): void {
242
+ writeCommonFiles(answers);
243
+ writeJson(path.join(answers.targetDir, "package.json"), {
244
+ name: answers.pluginId,
245
+ version: answers.pluginVersion,
246
+ private: true,
247
+ scripts: {
248
+ build: "openvcs dist --plugin-dir . --out dist",
249
+ test: "openvcs dist --plugin-dir . --out dist --no-npm-deps",
250
+ },
251
+ devDependencies: {
252
+ "@openvcs/sdk": `^${packageJson.version}`,
253
+ },
254
+ });
255
+ writeJson(path.join(answers.targetDir, "themes", "default", "theme.json"), {
256
+ name: answers.pluginName || "Default Theme",
257
+ description: "Starter OpenVCS theme generated by @openvcs/sdk",
258
+ tokens: {
259
+ accent: "#2a7fff",
260
+ background: "#0f172a",
261
+ foreground: "#e2e8f0",
262
+ },
263
+ });
264
+ }
265
+
266
+ export async function runInitCommand(args: string[]): Promise<string> {
267
+ let forceTheme = false;
268
+ let targetHint: string | undefined;
269
+
270
+ for (const arg of args) {
271
+ if (arg === "--theme") {
272
+ forceTheme = true;
273
+ continue;
274
+ }
275
+ if (arg === "--help") {
276
+ const error = new Error(initUsage()) as UsageError;
277
+ error.code = "USAGE";
278
+ throw error;
279
+ }
280
+ if (arg.startsWith("-")) {
281
+ throw new Error(`unknown argument for init: ${arg}`);
282
+ }
283
+ if (targetHint) {
284
+ throw new Error("init accepts at most one target directory");
285
+ }
286
+ targetHint = arg;
287
+ }
288
+
289
+ const answers = await collectAnswers({ forceTheme, targetHint });
290
+ if (!fs.existsSync(answers.targetDir)) {
291
+ fs.mkdirSync(answers.targetDir, { recursive: true });
292
+ } else if (!fs.lstatSync(answers.targetDir).isDirectory()) {
293
+ throw new Error(`target path exists but is not a directory: ${answers.targetDir}`);
294
+ } else if (directoryHasEntries(answers.targetDir)) {
295
+ const rl = readline.createInterface({ input: stdin, output: stdout });
296
+ try {
297
+ const proceed = await promptBoolean(
298
+ rl,
299
+ `Directory ${answers.targetDir} is not empty. Continue and overwrite known files`,
300
+ false
301
+ );
302
+ if (!proceed) {
303
+ throw new Error("aborted by user");
304
+ }
305
+ } finally {
306
+ rl.close();
307
+ }
308
+ }
309
+
310
+ if (answers.kind === "module") {
311
+ writeModuleTemplate(answers);
312
+ } else {
313
+ writeThemeTemplate(answers);
314
+ }
315
+
316
+ if (answers.runNpmInstall) {
317
+ runNpmInstall(answers.targetDir);
318
+ }
319
+
320
+ return answers.targetDir;
321
+ }
322
+
323
+ export function isUsageError(error: unknown): error is InitCommandError {
324
+ return (
325
+ typeof error === "object" &&
326
+ error !== null &&
327
+ "code" in error &&
328
+ typeof (error as InitCommandError).code === "string"
329
+ );
330
+ }
@@ -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
+ });