devsplain 2.3.1 → 2.3.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/lib/config.js CHANGED
@@ -4,7 +4,10 @@ const os = require('os');
4
4
  const readline = require('readline');
5
5
  const configPath = path.join(os.homedir(), '.devsplainrc');
6
6
 
7
- /** Prompts the user for sensitive input with hidden characters and raw stdin handling [ds] */
7
+ // Prompts user for a secret (API key) with masked input by using raw-mode stdin [ds]
8
+ // instead of readline's question(), which cannot suppress terminal echo. Manually [ds]
9
+ // handles Enter, Backspace, and Ctrl+C (SIGINT) so the typed characters never [ds]
10
+ // appear on screen. Returns a Promise that resolves once the user presses Enter. [ds]
8
11
  function askSecret(query) {
9
12
  return new Promise((resolve) => {
10
13
  const rl = readline.createInterface({
@@ -12,21 +15,33 @@ function askSecret(query) {
12
15
  output: process.stdout
13
16
  });
14
17
  process.stdout.write(query);
18
+ // Switch stdin into raw mode to prevent the terminal from echoing typed characters [ds]
19
+ // and to receive keystrokes one-by-one via the 'data' event. [ds]
15
20
  process.stdin.setRawMode(true);
16
21
  let key = '';
22
+ // Per-keystroke handler. Raw-mode data events may deliver multi-byte chunks, [ds]
23
+ // so we coerce to string first. Ctrl+C is treated specially because raw mode [ds]
24
+ // disables the OS-level SIGINT delivery, so we must exit manually with 130. [ds]
17
25
  const onData = (char) => {
18
26
  char = char.toString();
19
27
  if (char === '\n' || char === '\r') {
28
+ // Restore canonical (cooked) mode before exiting raw capture so subsequent [ds]
29
+ // prompts / terminal behavior behave normally. [ds]
20
30
  process.stdin.setRawMode(false);
21
31
  process.stdin.removeListener('data', onData);
22
32
  process.stdout.write('\n');
23
33
  rl.close();
24
34
  resolve(key);
35
+ // Handle both DEL (0x7f, sent by most terminals' Backspace) and BS (0x08, [ds]
36
+ // sent by some legacy terminals). Erase visually with '\b \b' since we cannot [ds]
37
+ // rely on the terminal to redraw the masked line. [ds]
25
38
  } else if (char === '\u0008' || char === '\u007f') {
26
39
  if (key.length > 0) {
27
40
  key = key.slice(0, -1);
28
41
  process.stdout.write('\b \b');
29
42
  }
43
+ // 0x03 is Ctrl+C. In raw mode the default SIGINT handler is bypassed, so we [ds]
44
+ // exit with the conventional 130 code (128 + SIGINT) to match shell expectations. [ds]
30
45
  } else if (char === '\u0003') {
31
46
  process.stdin.setRawMode(false);
32
47
  process.exit(130);
@@ -39,8 +54,14 @@ function askSecret(query) {
39
54
  });
40
55
  }
41
56
 
42
- /** Migrates legacy single-provider config structure to the new multi-provider format [ds] */
57
+ // Normalizes legacy single-provider configs (shape: { provider, apiKey, model, [ds]
58
+ // baseUrl, autoPrune }) into the current multi-provider schema (shape: [ds]
59
+ // { activeProvider, providers: { <name>: {...} } }). This lets users with older [ds]
60
+ // config files keep their credentials without re-running the wizard. [ds]
43
61
  function migrateConfig(oldConfig) {
62
+ // Only migrate when the old shape is detected: a top-level `provider` string [ds]
63
+ // exists AND the new `providers` map is absent. Guards against undefined/null [ds]
64
+ // and against already-migrated configs. [ds]
44
65
  if (oldConfig && typeof oldConfig === 'object' && oldConfig.provider && !oldConfig.providers) {
45
66
  return {
46
67
  activeProvider: oldConfig.provider,
@@ -57,10 +78,19 @@ function migrateConfig(oldConfig) {
57
78
  return oldConfig;
58
79
  }
59
80
 
60
- /** Resolves config from environment variables, file storage, or interactive wizard setup [ds] */
81
+ // Loads configuration for the CLI. Resolution order: (1) environment variables [ds]
82
+ // (non-interactive / CI-friendly, takes precedence), (2) existing config file at [ds]
83
+ // ~/.devsplainrc, (3) interactive wizard as a fallback. When `forceWizard` is [ds]
84
+ // true the wizard runs even if a valid file config exists. [ds]
61
85
  async function getConfig(forceWizard = false) {
86
+ // Env-var path short-circuits the file lookup and wizard entirely, allowing [ds]
87
+ // headless invocation. Either DEVSPLAIN_API_KEY or DEVSPLAIN_PROVIDER is enough [ds]
88
+ // to trigger this branch. [ds]
62
89
  if (process.env.DEVSPLAIN_API_KEY || process.env.DEVSPLAIN_PROVIDER) {
63
90
  const provider = process.env.DEVSPLAIN_PROVIDER || 'gemini';
91
+ // Nested ternaries select a sensible default model per provider so that setting [ds]
92
+ // only DEVSPLAIN_PROVIDER (without an explicit DEVSPLAIN_MODEL) yields a working [ds]
93
+ // configuration out of the box. [ds]
64
94
  const model = process.env.DEVSPLAIN_MODEL || (
65
95
  provider === 'gemini' ? 'gemini-2.0-flash' : (
66
96
  provider === 'claude' ? 'claude-3-5-sonnet-20240620' : (
@@ -70,6 +100,9 @@ async function getConfig(forceWizard = false) {
70
100
  )
71
101
  )
72
102
  );
103
+ // Similarly derive the default base URL. Gemini uses the SDK's built-in endpoint [ds]
104
+ // (so baseUrl stays null), while OpenAI-compatible providers need an explicit [ds]
105
+ // host. Groq is the else-branch default since it exposes an OpenAI-compatible API. [ds]
73
106
  const baseUrl = process.env.DEVSPLAIN_BASE_URL || (
74
107
  provider === 'gemini' ? null : (
75
108
  provider === 'claude' ? 'https://api.anthropic.com' : (
@@ -88,6 +121,9 @@ async function getConfig(forceWizard = false) {
88
121
  }
89
122
 
90
123
  let fileConfig = null;
124
+ // Parse the on-disk config defensively: missing/malformed JSON should not crash [ds]
125
+ // the CLI. Any error (read failure, JSON parse error) is swallowed and we fall [ds]
126
+ // through to the wizard so the user can recreate the config. [ds]
91
127
  if (fs.existsSync(configPath)) {
92
128
  try {
93
129
  const rawData = fs.readFileSync(configPath, 'utf8');
@@ -96,6 +132,9 @@ async function getConfig(forceWizard = false) {
96
132
  }
97
133
  }
98
134
 
135
+ // Enter the interactive wizard when no usable config exists OR when the caller [ds]
136
+ // explicitly forces it. A config is considered usable only if activeProvider, [ds]
137
+ // providers, and the entry for the active provider are all present. [ds]
99
138
  if (!fileConfig || !fileConfig.activeProvider || !fileConfig.providers || !fileConfig.providers[fileConfig.activeProvider] || forceWizard) {
100
139
  let rl = readline.createInterface({
101
140
  input: process.stdin,
@@ -106,12 +145,17 @@ async function getConfig(forceWizard = false) {
106
145
  let config = fileConfig || { activeProvider: '', providers: {} };
107
146
  let confirmed = false;
108
147
 
148
+ // Outer loop keeps the user in the wizard on invalid input (instead of aborting) [ds]
149
+ // until they complete a full configuration and confirm it. [ds]
109
150
  while (!confirmed) {
110
151
  const savedProviders = Object.keys(config.providers);
111
152
  let providerToConfig = null;
112
153
  let wantToUpdate = true;
113
154
  let isNewProvider = false;
114
155
 
156
+ // If providers already exist (e.g. wizard invoked via forceWizard on an existing [ds]
157
+ // config), display a numbered menu so the user can pick an existing one to update [ds]
158
+ // or add a new one. [ds]
115
159
  if (savedProviders.length > 0) {
116
160
  console.log("\nSaved Providers:");
117
161
  savedProviders.forEach((p, i) => {
@@ -122,6 +166,8 @@ async function getConfig(forceWizard = false) {
122
166
  const c = await askQuestion(`Select (1-${savedProviders.length + 1}): `);
123
167
  const idx = parseInt(c) - 1;
124
168
 
169
+ // Option index 0..len-1 maps to an existing provider; index === len is the [ds]
170
+ // 'Add/Configure a different provider' sentinel. Anything else is invalid input. [ds]
125
171
  if (idx >= 0 && idx < savedProviders.length) {
126
172
  providerToConfig = savedProviders[idx];
127
173
  const update = await askQuestion(`Do you want to update the API key or model for ${providerToConfig}? (y/N): `);
@@ -145,6 +191,9 @@ async function getConfig(forceWizard = false) {
145
191
  let autoPrune = false;
146
192
 
147
193
  if (wantToUpdate) {
194
+ // Provider-selection flow only runs when we actually want to modify something. [ds]
195
+ // When updating an existing provider and the user declined the y/N prompt, we [ds]
196
+ // skip straight to confirmation with the existing values intact. [ds]
148
197
  if (isNewProvider || !providerToConfig) {
149
198
  console.log("\nWhich AI Provider Do You want to use?");
150
199
  console.log("1. Groq (Free, Fast, Llama-3)");
@@ -156,10 +205,15 @@ async function getConfig(forceWizard = false) {
156
205
 
157
206
  const choice = await askQuestion("Select (1-6): ");
158
207
 
208
+ // Preferred-provider menu. Most options pre-fill baseUrl and offer a default [ds]
209
+ // model so users can just press Enter; 'Custom' falls through to the loop below [ds]
210
+ // that forces non-empty model and baseUrl values. [ds]
159
211
  if (choice === '1') {
160
212
  provider = 'groq';
161
213
  baseUrl = 'https://api.groq.com/openai';
162
214
  console.log("\nGet your free Groq key here: https://console.groq.com/keys");
215
+ // Empty input is treated as 'use the default'; trim() guards against stray [ds]
216
+ // whitespace counting as a real value. [ds]
163
217
  const customModel = await askQuestion("Model name (press Enter for default 'llama-3.3-70b-versatile'): ");
164
218
  model = customModel.trim() || 'llama-3.3-70b-versatile';
165
219
  } else if (choice === '2') {
@@ -176,11 +230,15 @@ async function getConfig(forceWizard = false) {
176
230
  model = customModel.trim() || 'gpt-4o';
177
231
  } else if (choice === '4') {
178
232
  provider = 'custom';
233
+ // Loop because an empty model name would produce a broken request later; keep [ds]
234
+ // re-prompting until the user supplies a non-empty value. [ds]
179
235
  while (true) {
180
236
  model = (await askQuestion("Model name (e.g., llama3): ")).trim();
181
237
  if (model) break;
182
238
  console.log("Model name cannot be empty.");
183
239
  }
240
+ // Same rationale as the model loop: a custom provider cannot function without [ds]
241
+ // a base URL, so we refuse to accept an empty answer. [ds]
184
242
  while (true) {
185
243
  baseUrl = (await askQuestion("Base URL (e.g., http://localhost:11434): ")).trim();
186
244
  if (baseUrl) break;
@@ -190,18 +248,24 @@ async function getConfig(forceWizard = false) {
190
248
  provider = 'claude';
191
249
  baseUrl = 'https://api.anthropic.com';
192
250
  console.log("\nGet your Anthropic key here: https://console.anthropic.com/settings/keys");
251
+ // Offer a default model so users unfamiliar with Anthropic's naming don't have to look up the identifier; an empty trimmed input falls back via short-circuit OR. [ds]
193
252
  const customModel = await askQuestion("Model name (press Enter for default 'claude-3-5-sonnet-20240620'): ");
194
253
  model = customModel.trim() || 'claude-3-5-sonnet-20240620';
195
254
  } else if (choice === '6') {
196
255
  provider = 'deepseek';
197
256
  baseUrl = 'https://api.deepseek.com';
198
257
  console.log("\nGet your DeepSeek key here: https://platform.deepseek.com/api_keys");
258
+ // Same default-model fallback pattern as the Anthropic branch: empty Enter preserves the DeepSeek default, avoiding an undefined model string. [ds]
199
259
  const customModel = await askQuestion("Model name (press Enter for default 'deepseek-chat'): ");
200
260
  model = customModel.trim() || 'deepseek-chat';
261
+ // Any unrecognized menu selection restarts the loop via `continue` rather than throwing, so the wizard remains re-entrant and never partially writes config. [ds]
201
262
  } else {
263
+ // Invalid menu selection restarts the outer while-loop without persisting [ds]
264
+ // anything, so partial state is discarded and the user gets a clean retry. [ds]
202
265
  console.log("Invalid choice. Please select 1, 2, 3, 4, 5, or 6.");
203
266
  continue;
204
267
  }
268
+ // Edit-existing-provider branch: reuse the stored baseUrl/model as defaults so the user can simply hit Enter to keep prior values. The custom provider additionally re-prompts for baseUrl since users may self-host at a different address. [ds]
205
269
  } else {
206
270
  provider = providerToConfig;
207
271
  const old = config.providers[provider];
@@ -210,17 +274,20 @@ async function getConfig(forceWizard = false) {
210
274
  const customModel = await askQuestion(`Model name (press Enter for default '${defaultModel}'): `);
211
275
  model = customModel.trim() || defaultModel;
212
276
 
277
+ // Only the 'custom' provider exposes its baseUrl for editing; hosted providers pin their endpoints to avoid users accidentally pointing Anthropic/DeepSeek credentials at attacker-controlled hosts. [ds]
213
278
  if (provider === 'custom') {
214
279
  const customBase = await askQuestion(`Base URL (press Enter for default '${baseUrl}'): `);
215
280
  baseUrl = customBase.trim() || baseUrl;
216
281
  }
217
282
  }
218
283
 
284
+ // API key acquisition loop. It must re-prompt on empty input for hosted providers but must allow empty keys for local/custom endpoints (e.g. Ollama) which don't require authentication. [ds]
219
285
  while (true) {
220
286
  const promptMsg = provider === 'custom'
221
287
  ? "Paste your API key (leave blank for local models): "
222
288
  : "Paste your API key: ";
223
289
 
290
+ // When stdin is a TTY we temporarily close the readline interface so raw-mode secret input (echo suppressed) can be read without readline echoing the typed characters back. After capturing the secret we re-create the readline interface and rebuild the askQuestion helper bound to the new rl instance. [ds]
224
291
  if (process.stdin.isTTY) {
225
292
  rl.close();
226
293
  apiKey = await askSecret(promptMsg);
@@ -230,17 +297,20 @@ async function getConfig(forceWizard = false) {
230
297
  output: process.stdout
231
298
  });
232
299
  askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
300
+ // Non-TTY (piped/CI) input cannot use raw mode; fall back to plain readline so the key still gets read but will be visible in the stream — acceptable for automated provisioning. [ds]
233
301
  } else {
234
302
  apiKey = await askQuestion(promptMsg);
235
303
  }
236
304
 
237
305
  apiKey = apiKey.trim();
306
+ // Custom/local providers explicitly bypass the non-empty key requirement; hosted providers must supply a non-empty key or we loop and warn the user. [ds]
238
307
  if (provider === 'custom' || apiKey) {
239
308
  break;
240
309
  }
241
310
  console.log(`API key is required for provider '${provider}'.`);
242
311
  }
243
312
 
313
+ // Tri-state confirmation for aggressive pruning. Accepting 'y'/'yes'/'n'/'no'/empty makes the prompt forgiving; anything else re-prompts to avoid silently misconfiguring destructive overwrite behavior. [ds]
244
314
  while (true) {
245
315
  const pruneAns = (await askQuestion("Do you want devsplain to aggressively prune (overwrite) existing human/AI comments? (y/n, default: n): ")).trim().toLowerCase();
246
316
  if (pruneAns === '' || pruneAns === 'n' || pruneAns === 'no') {
@@ -257,14 +327,17 @@ async function getConfig(forceWizard = false) {
257
327
  console.log(`Provider: ${provider}`);
258
328
  console.log(`Model: ${model}`);
259
329
  console.log(`Base URL: ${baseUrl || 'N/A'}`);
330
+ // Mask the API key in the summary while preserving the first 4 chars for user recognition. Math.max(0, ...) guards against keys shorter than 4 chars which would otherwise produce a negative repeat count and throw. [ds]
260
331
  console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
261
332
  console.log(`Auto-Prune: ${autoPrune ? 'Yes' : 'No'}`);
262
333
  console.log("-----------------------------\n");
263
334
 
335
+ // Final confirmation gate. Only on explicit 'y' (or bare Enter, defaulting to yes) do we commit the provider into config.providers and set it as active. Choosing 'n' breaks out without mutating config, effectively cancelling the wizard. `confirmed` signals the outer loop to exit. [ds]
264
336
  while (true) {
265
337
  const confirm = (await askQuestion("Does this look correct? (y/n, default: y): ")).trim().toLowerCase();
266
338
  if (confirm === '' || confirm === 'y' || confirm === 'yes') {
267
339
  config.activeProvider = provider;
340
+ // Persist the collected settings under the provider key. Overwrites any prior entry for this provider, which is the intended behavior when re-running the setup wizard for an existing provider. [ds]
268
341
  config.providers[provider] = {
269
342
  apiKey,
270
343
  model,
@@ -278,6 +351,7 @@ async function getConfig(forceWizard = false) {
278
351
  }
279
352
  console.log("Invalid choice. Please enter 'y' or 'n'.");
280
353
  }
354
+ // The user chose to merely switch the active provider without re-entering credentials; mark confirmed so the enclosing loop terminates and proceed to write. [ds]
281
355
  } else {
282
356
  config.activeProvider = providerToConfig;
283
357
  console.log(`\nSwitched active provider to ${config.activeProvider}.`);
@@ -287,18 +361,22 @@ async function getConfig(forceWizard = false) {
287
361
 
288
362
  rl.close();
289
363
 
364
+ // Persist config with 2-space indentation for human readability. Writing synchronously ensures the process cannot exit before the file is flushed. [ds]
290
365
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
366
+ // Harden file permissions to owner-only (0600) on POSIX systems because config stores API keys in plaintext. Windows lacks POSIX mode semantics, hence the platform guard, and chmod failures are swallowed since the config is already written. [ds]
291
367
  try {
292
368
  if (process.platform !== 'win32') {
293
369
  fs.chmodSync(configPath, 0o600);
294
370
  }
295
371
  } catch (chmodErr) {}
296
372
 
373
+ // Return a flattened view of the active provider so callers don't have to remember whether they need config.activeProvider or config.providers[...]; the spread exposes apiKey/model/baseUrl/autoPrune at the top level. [ds]
297
374
  return {
298
375
  provider: config.activeProvider,
299
376
  ...config.providers[config.activeProvider]
300
377
  };
301
378
  } else {
379
+ // Read-only path: no wizard needed. The `|| {}` defends against a stale activeProvider name that no longer exists in the providers map, ensuring the spread below never throws on undefined. [ds]
302
380
  const active = fileConfig.providers[fileConfig.activeProvider] || {};
303
381
  return {
304
382
  provider: fileConfig.activeProvider,