devsplain 2.3.0 → 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,12 +78,40 @@ 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';
64
- const model = process.env.DEVSPLAIN_MODEL || (provider === 'gemini' ? 'gemini-2.0-flash' : (provider === 'claude' ? 'claude-3-5-sonnet-20240620' : 'llama-3.3-70b-versatile'));
65
- const baseUrl = process.env.DEVSPLAIN_BASE_URL || (provider === 'gemini' ? null : (provider === 'claude' ? 'https://api.anthropic.com' : 'https://api.groq.com/openai'));
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]
94
+ const model = process.env.DEVSPLAIN_MODEL || (
95
+ provider === 'gemini' ? 'gemini-2.0-flash' : (
96
+ provider === 'claude' ? 'claude-3-5-sonnet-20240620' : (
97
+ provider === 'deepseek' ? 'deepseek-chat' : (
98
+ provider === 'openai' ? 'gpt-4o' : 'llama-3.3-70b-versatile'
99
+ )
100
+ )
101
+ )
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]
106
+ const baseUrl = process.env.DEVSPLAIN_BASE_URL || (
107
+ provider === 'gemini' ? null : (
108
+ provider === 'claude' ? 'https://api.anthropic.com' : (
109
+ provider === 'deepseek' ? 'https://api.deepseek.com' : (
110
+ provider === 'openai' ? 'https://api.openai.com' : 'https://api.groq.com/openai'
111
+ )
112
+ )
113
+ )
114
+ );
66
115
  return {
67
116
  provider,
68
117
  apiKey: process.env.DEVSPLAIN_API_KEY || '',
@@ -72,6 +121,9 @@ async function getConfig(forceWizard = false) {
72
121
  }
73
122
 
74
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]
75
127
  if (fs.existsSync(configPath)) {
76
128
  try {
77
129
  const rawData = fs.readFileSync(configPath, 'utf8');
@@ -80,6 +132,9 @@ async function getConfig(forceWizard = false) {
80
132
  }
81
133
  }
82
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]
83
138
  if (!fileConfig || !fileConfig.activeProvider || !fileConfig.providers || !fileConfig.providers[fileConfig.activeProvider] || forceWizard) {
84
139
  let rl = readline.createInterface({
85
140
  input: process.stdin,
@@ -90,12 +145,17 @@ async function getConfig(forceWizard = false) {
90
145
  let config = fileConfig || { activeProvider: '', providers: {} };
91
146
  let confirmed = false;
92
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]
93
150
  while (!confirmed) {
94
151
  const savedProviders = Object.keys(config.providers);
95
152
  let providerToConfig = null;
96
153
  let wantToUpdate = true;
97
154
  let isNewProvider = false;
98
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]
99
159
  if (savedProviders.length > 0) {
100
160
  console.log("\nSaved Providers:");
101
161
  savedProviders.forEach((p, i) => {
@@ -106,6 +166,8 @@ async function getConfig(forceWizard = false) {
106
166
  const c = await askQuestion(`Select (1-${savedProviders.length + 1}): `);
107
167
  const idx = parseInt(c) - 1;
108
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]
109
171
  if (idx >= 0 && idx < savedProviders.length) {
110
172
  providerToConfig = savedProviders[idx];
111
173
  const update = await askQuestion(`Do you want to update the API key or model for ${providerToConfig}? (y/N): `);
@@ -129,6 +191,9 @@ async function getConfig(forceWizard = false) {
129
191
  let autoPrune = false;
130
192
 
131
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]
132
197
  if (isNewProvider || !providerToConfig) {
133
198
  console.log("\nWhich AI Provider Do You want to use?");
134
199
  console.log("1. Groq (Free, Fast, Llama-3)");
@@ -136,13 +201,19 @@ async function getConfig(forceWizard = false) {
136
201
  console.log("3. OpenAI (Paid)");
137
202
  console.log("4. Custom (Ollama, local, etc)");
138
203
  console.log("5. Claude (Anthropic)");
204
+ console.log("6. DeepSeek (deepseek-chat, deepseek-reasoner)");
139
205
 
140
- const choice = await askQuestion("Select (1-5): ");
206
+ const choice = await askQuestion("Select (1-6): ");
141
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]
142
211
  if (choice === '1') {
143
212
  provider = 'groq';
144
213
  baseUrl = 'https://api.groq.com/openai';
145
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]
146
217
  const customModel = await askQuestion("Model name (press Enter for default 'llama-3.3-70b-versatile'): ");
147
218
  model = customModel.trim() || 'llama-3.3-70b-versatile';
148
219
  } else if (choice === '2') {
@@ -159,11 +230,15 @@ async function getConfig(forceWizard = false) {
159
230
  model = customModel.trim() || 'gpt-4o';
160
231
  } else if (choice === '4') {
161
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]
162
235
  while (true) {
163
236
  model = (await askQuestion("Model name (e.g., llama3): ")).trim();
164
237
  if (model) break;
165
238
  console.log("Model name cannot be empty.");
166
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]
167
242
  while (true) {
168
243
  baseUrl = (await askQuestion("Base URL (e.g., http://localhost:11434): ")).trim();
169
244
  if (baseUrl) break;
@@ -173,12 +248,24 @@ async function getConfig(forceWizard = false) {
173
248
  provider = 'claude';
174
249
  baseUrl = 'https://api.anthropic.com';
175
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]
176
252
  const customModel = await askQuestion("Model name (press Enter for default 'claude-3-5-sonnet-20240620'): ");
177
253
  model = customModel.trim() || 'claude-3-5-sonnet-20240620';
254
+ } else if (choice === '6') {
255
+ provider = 'deepseek';
256
+ baseUrl = 'https://api.deepseek.com';
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]
259
+ const customModel = await askQuestion("Model name (press Enter for default 'deepseek-chat'): ");
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]
178
262
  } else {
179
- console.log("Invalid choice. Please select 1, 2, 3, 4, or 5.");
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]
265
+ console.log("Invalid choice. Please select 1, 2, 3, 4, 5, or 6.");
180
266
  continue;
181
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]
182
269
  } else {
183
270
  provider = providerToConfig;
184
271
  const old = config.providers[provider];
@@ -187,17 +274,20 @@ async function getConfig(forceWizard = false) {
187
274
  const customModel = await askQuestion(`Model name (press Enter for default '${defaultModel}'): `);
188
275
  model = customModel.trim() || defaultModel;
189
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]
190
278
  if (provider === 'custom') {
191
279
  const customBase = await askQuestion(`Base URL (press Enter for default '${baseUrl}'): `);
192
280
  baseUrl = customBase.trim() || baseUrl;
193
281
  }
194
282
  }
195
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]
196
285
  while (true) {
197
286
  const promptMsg = provider === 'custom'
198
287
  ? "Paste your API key (leave blank for local models): "
199
288
  : "Paste your API key: ";
200
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]
201
291
  if (process.stdin.isTTY) {
202
292
  rl.close();
203
293
  apiKey = await askSecret(promptMsg);
@@ -207,17 +297,20 @@ async function getConfig(forceWizard = false) {
207
297
  output: process.stdout
208
298
  });
209
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]
210
301
  } else {
211
302
  apiKey = await askQuestion(promptMsg);
212
303
  }
213
304
 
214
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]
215
307
  if (provider === 'custom' || apiKey) {
216
308
  break;
217
309
  }
218
310
  console.log(`API key is required for provider '${provider}'.`);
219
311
  }
220
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]
221
314
  while (true) {
222
315
  const pruneAns = (await askQuestion("Do you want devsplain to aggressively prune (overwrite) existing human/AI comments? (y/n, default: n): ")).trim().toLowerCase();
223
316
  if (pruneAns === '' || pruneAns === 'n' || pruneAns === 'no') {
@@ -234,14 +327,17 @@ async function getConfig(forceWizard = false) {
234
327
  console.log(`Provider: ${provider}`);
235
328
  console.log(`Model: ${model}`);
236
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]
237
331
  console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
238
332
  console.log(`Auto-Prune: ${autoPrune ? 'Yes' : 'No'}`);
239
333
  console.log("-----------------------------\n");
240
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]
241
336
  while (true) {
242
337
  const confirm = (await askQuestion("Does this look correct? (y/n, default: y): ")).trim().toLowerCase();
243
338
  if (confirm === '' || confirm === 'y' || confirm === 'yes') {
244
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]
245
341
  config.providers[provider] = {
246
342
  apiKey,
247
343
  model,
@@ -255,6 +351,7 @@ async function getConfig(forceWizard = false) {
255
351
  }
256
352
  console.log("Invalid choice. Please enter 'y' or 'n'.");
257
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]
258
355
  } else {
259
356
  config.activeProvider = providerToConfig;
260
357
  console.log(`\nSwitched active provider to ${config.activeProvider}.`);
@@ -264,18 +361,22 @@ async function getConfig(forceWizard = false) {
264
361
 
265
362
  rl.close();
266
363
 
364
+ // Persist config with 2-space indentation for human readability. Writing synchronously ensures the process cannot exit before the file is flushed. [ds]
267
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]
268
367
  try {
269
368
  if (process.platform !== 'win32') {
270
369
  fs.chmodSync(configPath, 0o600);
271
370
  }
272
371
  } catch (chmodErr) {}
273
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]
274
374
  return {
275
375
  provider: config.activeProvider,
276
376
  ...config.providers[config.activeProvider]
277
377
  };
278
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]
279
380
  const active = fileConfig.providers[fileConfig.activeProvider] || {};
280
381
  return {
281
382
  provider: fileConfig.activeProvider,