@openvcs/sdk 0.2.1 → 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.
package/README.md CHANGED
@@ -57,6 +57,8 @@ openvcs init my-plugin
57
57
  ```
58
58
 
59
59
  The generated module template includes TypeScript and Node typings (`@types/node`).
60
+ Plugin IDs entered during scaffold must not be `.`/`..` and must not contain path
61
+ separators (`/` or `\\`).
60
62
 
61
63
  Interactive theme plugin scaffold:
62
64
 
package/lib/dist.js CHANGED
@@ -63,12 +63,30 @@ function parseDistArgs(args) {
63
63
  }
64
64
  function readManifest(pluginDir) {
65
65
  const manifestPath = path.join(pluginDir, "openvcs.plugin.json");
66
- if (!fs.existsSync(manifestPath) || !fs.statSync(manifestPath).isFile()) {
67
- throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
68
- }
66
+ let manifestRaw;
67
+ let manifestFd;
69
68
  let manifest;
70
69
  try {
71
- manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
70
+ manifestFd = fs.openSync(manifestPath, "r");
71
+ const manifestStat = fs.fstatSync(manifestFd);
72
+ if (!manifestStat.isFile()) {
73
+ throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
74
+ }
75
+ manifestRaw = fs.readFileSync(manifestFd, "utf8");
76
+ }
77
+ catch (error) {
78
+ if (error.code === "ENOENT") {
79
+ throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
80
+ }
81
+ throw error;
82
+ }
83
+ finally {
84
+ if (typeof manifestFd === "number") {
85
+ fs.closeSync(manifestFd);
86
+ }
87
+ }
88
+ try {
89
+ manifest = JSON.parse(manifestRaw);
72
90
  }
73
91
  catch (error) {
74
92
  const detail = error instanceof Error ? error.message : String(error);
@@ -122,24 +140,15 @@ function runCommand(program, args, cwd, verbose) {
122
140
  }
123
141
  const result = (0, node_child_process_1.spawnSync)(program, args, {
124
142
  cwd,
125
- encoding: "utf8",
126
- stdio: ["ignore", "pipe", "pipe"],
143
+ stdio: ["ignore", verbose ? "inherit" : "ignore", "inherit"],
127
144
  });
128
145
  if (result.error) {
129
146
  throw new Error(`failed to spawn '${program}' in ${cwd}: ${result.error.message}`);
130
147
  }
131
148
  if (result.status === 0) {
132
- if (verbose) {
133
- if (result.stdout?.trim()) {
134
- process.stderr.write(`${result.stdout.trim()}\n`);
135
- }
136
- if (result.stderr?.trim()) {
137
- process.stderr.write(`${result.stderr.trim()}\n`);
138
- }
139
- }
140
149
  return;
141
150
  }
142
- throw new Error(`command failed (${program} ${args.join(" ")}), exit code ${result.status}, stdout='${(result.stdout || "").trim()}', stderr='${(result.stderr || "").trim()}'`);
151
+ throw new Error(`command failed (${program} ${args.join(" ")}), exit code ${result.status}`);
143
152
  }
144
153
  function ensurePackageLock(pluginDir, verbose) {
145
154
  if (!hasPackageJson(pluginDir)) {
package/lib/init.d.ts CHANGED
@@ -1,7 +1,37 @@
1
+ interface InitAnswers {
2
+ targetDir: string;
3
+ kind: "module" | "theme";
4
+ pluginId: string;
5
+ pluginName: string;
6
+ pluginVersion: string;
7
+ defaultEnabled: boolean;
8
+ runNpmInstall: boolean;
9
+ }
10
+ interface CollectAnswersOptions {
11
+ forceTheme: boolean;
12
+ targetHint?: string;
13
+ }
14
+ interface PromptDriver {
15
+ promptText(label: string, defaultValue?: string): Promise<string>;
16
+ promptBoolean(label: string, defaultValue: boolean): Promise<boolean>;
17
+ close(): void;
18
+ }
1
19
  interface InitCommandError {
2
20
  code?: string;
3
21
  }
4
22
  export declare function initUsage(commandName?: string): string;
23
+ declare function sanitizeIdToken(raw: string): string;
24
+ declare function defaultPluginIdFromDir(targetDir: string): string;
25
+ declare function validatePluginId(pluginId: string): string | undefined;
26
+ declare function createReadlinePromptDriver(output?: NodeJS.WritableStream): PromptDriver;
27
+ declare function collectAnswers({ forceTheme, targetHint }: CollectAnswersOptions, promptDriver?: PromptDriver, output?: NodeJS.WritableStream): Promise<InitAnswers>;
5
28
  export declare function runInitCommand(args: string[]): Promise<string>;
6
29
  export declare function isUsageError(error: unknown): error is InitCommandError;
30
+ export declare const __private: {
31
+ collectAnswers: typeof collectAnswers;
32
+ createReadlinePromptDriver: typeof createReadlinePromptDriver;
33
+ defaultPluginIdFromDir: typeof defaultPluginIdFromDir;
34
+ sanitizeIdToken: typeof sanitizeIdToken;
35
+ validatePluginId: typeof validatePluginId;
36
+ };
7
37
  export {};
package/lib/init.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.__private = void 0;
3
4
  exports.initUsage = initUsage;
4
5
  exports.runInitCommand = runInitCommand;
5
6
  exports.isUsageError = isUsageError;
@@ -45,28 +46,48 @@ function defaultPluginNameFromId(pluginId) {
45
46
  .map((word) => word[0].toUpperCase() + word.slice(1));
46
47
  return words.length > 0 ? words.join(" ") : "OpenVCS Plugin";
47
48
  }
48
- async function promptText(rl, label, defaultValue = "") {
49
- const suffix = defaultValue ? ` [${defaultValue}]` : "";
50
- const answer = await rl.question(`${label}${suffix}: `);
51
- const trimmed = answer.trim();
52
- return trimmed || defaultValue;
53
- }
54
- async function promptBoolean(rl, label, defaultValue) {
55
- const suffix = defaultValue ? "Y/n" : "y/N";
56
- while (true) {
57
- const answer = await rl.question(`${label} (${suffix}): `);
58
- const normalized = answer.trim().toLowerCase();
59
- if (!normalized) {
60
- return defaultValue;
61
- }
62
- if (normalized === "y" || normalized === "yes") {
63
- return true;
64
- }
65
- if (normalized === "n" || normalized === "no") {
66
- return false;
67
- }
68
- process.stderr.write("Please answer yes or no.\n");
49
+ function validatePluginId(pluginId) {
50
+ if (!pluginId) {
51
+ return "Plugin id is required.";
52
+ }
53
+ if (pluginId === "." || pluginId === "..") {
54
+ return "Plugin id must not be '.' or '..'.";
69
55
  }
56
+ if (pluginId.includes("/") || pluginId.includes("\\")) {
57
+ return "Plugin id must not contain path separators (/ or \\).";
58
+ }
59
+ return undefined;
60
+ }
61
+ function createReadlinePromptDriver(output = process.stderr) {
62
+ const rl = readline.createInterface({ input: node_process_1.stdin, output: node_process_1.stdout });
63
+ return {
64
+ async promptText(label, defaultValue = "") {
65
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
66
+ const answer = await rl.question(`${label}${suffix}: `);
67
+ const trimmed = answer.trim();
68
+ return trimmed || defaultValue;
69
+ },
70
+ async promptBoolean(label, defaultValue) {
71
+ const suffix = defaultValue ? "Y/n" : "y/N";
72
+ while (true) {
73
+ const answer = await rl.question(`${label} (${suffix}): `);
74
+ const normalized = answer.trim().toLowerCase();
75
+ if (!normalized) {
76
+ return defaultValue;
77
+ }
78
+ if (normalized === "y" || normalized === "yes") {
79
+ return true;
80
+ }
81
+ if (normalized === "n" || normalized === "no") {
82
+ return false;
83
+ }
84
+ output.write("Please answer yes or no.\n");
85
+ }
86
+ },
87
+ close() {
88
+ rl.close();
89
+ },
90
+ };
70
91
  }
71
92
  function writeJson(filePath, value) {
72
93
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -80,11 +101,10 @@ function directoryHasEntries(targetDir) {
80
101
  const entries = fs.readdirSync(targetDir);
81
102
  return entries.length > 0;
82
103
  }
83
- async function collectAnswers({ forceTheme, targetHint }) {
104
+ async function collectAnswers({ forceTheme, targetHint }, promptDriver = createReadlinePromptDriver(), output = process.stderr) {
84
105
  const defaultTarget = targetHint || path.join(process.cwd(), "openvcs-plugin");
85
- const rl = readline.createInterface({ input: node_process_1.stdin, output: node_process_1.stdout });
86
106
  try {
87
- const targetText = await promptText(rl, "Target directory", defaultTarget);
107
+ const targetText = await promptDriver.promptText("Target directory", defaultTarget);
88
108
  const targetDir = path.resolve(targetText);
89
109
  let kind = "module";
90
110
  if (forceTheme) {
@@ -92,7 +112,7 @@ async function collectAnswers({ forceTheme, targetHint }) {
92
112
  }
93
113
  else {
94
114
  while (true) {
95
- const value = (await promptText(rl, "Template type (module/theme)", "module"))
115
+ const value = (await promptDriver.promptText("Template type (module/theme)", "module"))
96
116
  .trim()
97
117
  .toLowerCase();
98
118
  if (value === "module" || value === "m") {
@@ -103,25 +123,31 @@ async function collectAnswers({ forceTheme, targetHint }) {
103
123
  kind = "theme";
104
124
  break;
105
125
  }
106
- process.stderr.write("Please choose 'module' or 'theme'.\n");
126
+ output.write("Please choose 'module' or 'theme'.\n");
107
127
  }
108
128
  }
109
129
  const defaultId = defaultPluginIdFromDir(targetDir);
110
- let pluginId = "";
130
+ let pluginId;
111
131
  while (!pluginId) {
112
- pluginId = (await promptText(rl, "Plugin id", defaultId)).trim();
132
+ const candidateId = (await promptDriver.promptText("Plugin id", defaultId)).trim();
133
+ const validationError = validatePluginId(candidateId);
134
+ if (!validationError) {
135
+ pluginId = candidateId;
136
+ break;
137
+ }
138
+ output.write(`${validationError}\n`);
113
139
  }
114
140
  const defaultName = defaultPluginNameFromId(pluginId);
115
141
  let pluginName = "";
116
142
  while (!pluginName) {
117
- pluginName = (await promptText(rl, "Plugin name", defaultName)).trim();
143
+ pluginName = (await promptDriver.promptText("Plugin name", defaultName)).trim();
118
144
  }
119
145
  let pluginVersion = "";
120
146
  while (!pluginVersion) {
121
- pluginVersion = (await promptText(rl, "Version", "0.1.0")).trim();
147
+ pluginVersion = (await promptDriver.promptText("Version", "0.1.0")).trim();
122
148
  }
123
- const defaultEnabled = await promptBoolean(rl, "Default enabled", true);
124
- const runNpmInstall = await promptBoolean(rl, "Run npm install now", true);
149
+ const defaultEnabled = await promptDriver.promptBoolean("Default enabled", true);
150
+ const runNpmInstall = await promptDriver.promptBoolean("Run npm install now", true);
125
151
  return {
126
152
  targetDir,
127
153
  kind,
@@ -133,7 +159,7 @@ async function collectAnswers({ forceTheme, targetHint }) {
133
159
  };
134
160
  }
135
161
  finally {
136
- rl.close();
162
+ promptDriver.close();
137
163
  }
138
164
  }
139
165
  function runNpmInstall(targetDir) {
@@ -244,15 +270,15 @@ async function runInitCommand(args) {
244
270
  throw new Error(`target path exists but is not a directory: ${answers.targetDir}`);
245
271
  }
246
272
  else if (directoryHasEntries(answers.targetDir)) {
247
- const rl = readline.createInterface({ input: node_process_1.stdin, output: node_process_1.stdout });
273
+ const promptDriver = createReadlinePromptDriver();
248
274
  try {
249
- const proceed = await promptBoolean(rl, `Directory ${answers.targetDir} is not empty. Continue and overwrite known files`, false);
275
+ const proceed = await promptDriver.promptBoolean(`Directory ${answers.targetDir} is not empty. Continue and overwrite known files`, false);
250
276
  if (!proceed) {
251
277
  throw new Error("aborted by user");
252
278
  }
253
279
  }
254
280
  finally {
255
- rl.close();
281
+ promptDriver.close();
256
282
  }
257
283
  }
258
284
  if (answers.kind === "module") {
@@ -272,3 +298,10 @@ function isUsageError(error) {
272
298
  "code" in error &&
273
299
  typeof error.code === "string");
274
300
  }
301
+ exports.__private = {
302
+ collectAnswers,
303
+ createReadlinePromptDriver,
304
+ defaultPluginIdFromDir,
305
+ sanitizeIdToken,
306
+ validatePluginId,
307
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/sdk",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "OpenVCS SDK CLI for plugin scaffolding and .ovcsp tar.gz packaging",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "homepage": "https://bbgames.dev/",
package/src/lib/dist.ts CHANGED
@@ -30,8 +30,6 @@ interface ManifestInfo {
30
30
  interface CommandResult {
31
31
  status: number | null;
32
32
  error?: Error;
33
- stdout?: string | null;
34
- stderr?: string | null;
35
33
  }
36
34
 
37
35
  function npmExecutable(): string {
@@ -92,13 +90,29 @@ export function parseDistArgs(args: string[]): DistArgs {
92
90
 
93
91
  function readManifest(pluginDir: string): ManifestInfo {
94
92
  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}`);
93
+ let manifestRaw: string;
94
+ let manifestFd: number | undefined;
95
+ let manifest: unknown;
96
+ try {
97
+ manifestFd = fs.openSync(manifestPath, "r");
98
+ const manifestStat = fs.fstatSync(manifestFd);
99
+ if (!manifestStat.isFile()) {
100
+ throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
101
+ }
102
+ manifestRaw = fs.readFileSync(manifestFd, "utf8");
103
+ } catch (error: unknown) {
104
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
105
+ throw new Error(`missing openvcs.plugin.json at ${manifestPath}`);
106
+ }
107
+ throw error;
108
+ } finally {
109
+ if (typeof manifestFd === "number") {
110
+ fs.closeSync(manifestFd);
111
+ }
97
112
  }
98
113
 
99
- let manifest: unknown;
100
114
  try {
101
- manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
115
+ manifest = JSON.parse(manifestRaw);
102
116
  } catch (error: unknown) {
103
117
  const detail = error instanceof Error ? error.message : String(error);
104
118
  throw new Error(`parse ${manifestPath}: ${detail}`);
@@ -161,28 +175,17 @@ function runCommand(program: string, args: string[], cwd: string, verbose: boole
161
175
 
162
176
  const result = spawnSync(program, args, {
163
177
  cwd,
164
- encoding: "utf8",
165
- stdio: ["ignore", "pipe", "pipe"],
178
+ stdio: ["ignore", verbose ? "inherit" : "ignore", "inherit"],
166
179
  }) as CommandResult;
167
180
 
168
181
  if (result.error) {
169
182
  throw new Error(`failed to spawn '${program}' in ${cwd}: ${result.error.message}`);
170
183
  }
171
184
  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
185
  return;
181
186
  }
182
187
 
183
- throw new Error(
184
- `command failed (${program} ${args.join(" ")}), exit code ${result.status}, stdout='${(result.stdout || "").trim()}', stderr='${(result.stderr || "").trim()}'`
185
- );
188
+ throw new Error(`command failed (${program} ${args.join(" ")}), exit code ${result.status}`);
186
189
  }
187
190
 
188
191
  function ensurePackageLock(pluginDir: string, verbose: boolean): void {
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
 
@@ -292,10 +319,9 @@ export async function runInitCommand(args: string[]): Promise<string> {
292
319
  } else if (!fs.lstatSync(answers.targetDir).isDirectory()) {
293
320
  throw new Error(`target path exists but is not a directory: ${answers.targetDir}`);
294
321
  } else if (directoryHasEntries(answers.targetDir)) {
295
- const rl = readline.createInterface({ input: stdin, output: stdout });
322
+ const promptDriver = createReadlinePromptDriver();
296
323
  try {
297
- const proceed = await promptBoolean(
298
- rl,
324
+ const proceed = await promptDriver.promptBoolean(
299
325
  `Directory ${answers.targetDir} is not empty. Continue and overwrite known files`,
300
326
  false
301
327
  );
@@ -303,7 +329,7 @@ export async function runInitCommand(args: string[]): Promise<string> {
303
329
  throw new Error("aborted by user");
304
330
  }
305
331
  } finally {
306
- rl.close();
332
+ promptDriver.close();
307
333
  }
308
334
  }
309
335
 
@@ -328,3 +354,11 @@ export function isUsageError(error: unknown): error is InitCommandError {
328
354
  typeof (error as InitCommandError).code === "string"
329
355
  );
330
356
  }
357
+
358
+ export const __private = {
359
+ collectAnswers,
360
+ createReadlinePromptDriver,
361
+ defaultPluginIdFromDir,
362
+ sanitizeIdToken,
363
+ validatePluginId,
364
+ };
@@ -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
+ });