dsh-switchman 1.3.1 → 1.6.0

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.
@@ -1,39 +1,43 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 配置解析:env 文件 / 环境变量 / 脱敏 / 安全错误格式化
5
- * dotenv 只用 parse(),不污染 process.env;POSIX 下检查 env 文件权限
4
+ * Configuration: env file / environment variables / sanitizing / safe error formatting
5
+ * Only dotenv.parse() is used so process.env stays untouched; env file
6
+ * permissions are checked on POSIX
6
7
  */
7
8
 
8
9
  const fs = require('fs');
9
10
  const path = require('path');
10
11
  const dotenv = require('dotenv');
11
12
 
12
- // ── env 文件加载(不污染 process.env)──
13
+ // ── Env file loading (does not touch process.env) ──
13
14
 
14
15
  /**
15
- * 加载指定 env 文件,返回键值对
16
- * 不读 cwd 的 .env,防止意外泄露;group/other 可读时直接拒绝(凭据安全)
17
- * @param {string|undefined} envFile - env 文件路径
18
- * @returns {object} 键值对
16
+ * Load the given env file and return key/value pairs
17
+ * Never reads .env from cwd to avoid accidental leaks; rejects outright when
18
+ * group/other can read it (credential safety)
19
+ * @param {string|undefined} envFile - env file path
20
+ * @returns {object} key/value pairs
19
21
  */
20
22
  function loadEnvFile(envFile) {
21
23
  if (!envFile) return {};
22
24
  const resolved = path.resolve(envFile);
23
25
  if (!fs.existsSync(resolved)) {
24
- throw new Error(`env 文件不存在: ${resolved}`);
26
+ throw new Error(`Env file not found: ${resolved}`);
25
27
  }
26
28
 
27
- // POSIX 下检查 group/other 权限:同机其他用户可读即泄露密码,直接拒绝
29
+ // Check group/other permissions on POSIX: if other local users can read the
30
+ // file the password leaks — reject outright
28
31
  try {
29
32
  const stat = fs.statSync(resolved);
30
33
  // eslint-disable-next-line no-bitwise
31
34
  if ((stat.mode & 0o077) !== 0) {
32
- throw new ConfigError(`env 文件 ${resolved} 权限过宽(group/other 可访问),请 chmod 600`);
35
+ throw new ConfigError(`Env file ${resolved} permissions too broad (accessible by group/other), run chmod 600`);
33
36
  }
34
37
  } catch (e) {
35
- // 仅跳过 Windows 等非 POSIX 系统的权限检查错误;其余错误(权限过宽)往上抛
36
- if (e.message.includes('权限过宽')) throw e;
38
+ // Only skip the permission-check error on Windows and other non-POSIX
39
+ // systems; rethrow everything else (permissions too broad)
40
+ if (e.message.includes('permissions too broad')) throw e;
37
41
  }
38
42
 
39
43
  const content = fs.readFileSync(resolved, 'utf8');
@@ -41,28 +45,30 @@ function loadEnvFile(envFile) {
41
45
  }
42
46
 
43
47
  /**
44
- * 按 SKILL.md 优先级读取配置值:CLI > env 文件 > 进程环境变量 > undefined
45
- * @param {object} envPairs - dotenv.parse() 结果(显式 --env-file,权限受控)
46
- * @param {string} envKey - 环境变量名(如 DB_QUERY_MYSQL_HOST)
47
- * @param {*} cliValue - CLI 传入的值;undefined 表示 CLI 未指定
48
- * @returns {*} CLI 优先,其次 env 文件,再次进程环境变量,否则 undefined
48
+ * Read a config value with SKILL.md precedence: CLI > env file > process env > undefined
49
+ * @param {object} envPairs - dotenv.parse() result (explicit --env-file, permission-controlled)
50
+ * @param {string} envKey - environment variable name (e.g. DB_QUERY_MYSQL_HOST)
51
+ * @param {*} cliValue - value from the CLI; undefined means not given on the CLI
52
+ * @returns {*} CLI first, then env file, then process env, otherwise undefined
49
53
  */
50
54
  function resolveEnv(envPairs, envKey, cliValue) {
51
55
  if (cliValue !== undefined) return cliValue;
52
- // env 文件优先(显式指定、权限受控 0600),其次进程环境变量
53
- // (loadEnvFile 刻意不污染 process.env,必须在此显式读取兜底,否则 export DB_QUERY_* 不生效)
56
+ // Env file first (explicitly given, permission-controlled 0600), then the
57
+ // process environment (loadEnvFile deliberately leaves process.env
58
+ // untouched, so fall back to it here or `export DB_QUERY_*` would not work)
54
59
  if (envPairs[envKey] !== undefined) return envPairs[envKey];
55
60
  if (process.env[envKey] !== undefined) return process.env[envKey];
56
61
  return undefined;
57
62
  }
58
63
 
59
64
  /**
60
- * 从 env/CLI 解析数值并校验 min/max 边界
61
- * 所有来源(CLI / env 文件 / 环境变量)统一跑边界检查,防止 env 文件绕过硬上限
65
+ * Parse a number from env/CLI and validate min/max bounds
66
+ * Every source (CLI / env file / environment) runs the same bounds check so
67
+ * an env file cannot bypass hard limits
62
68
  *
63
- * @param {object} envPairs - dotenv.parse() 结果
64
- * @param {string} envKey - 环境变量名
65
- * @param {*} cliValue - CLI 传入的值(已由 cli.js 做过边界校验)
69
+ * @param {object} envPairs - dotenv.parse() result
70
+ * @param {string} envKey - environment variable name
71
+ * @param {*} cliValue - value from the CLI (bounds already checked by cli.js)
66
72
  * @param {object} bounds - { min?: number, max?: number }
67
73
  * @returns {number}
68
74
  */
@@ -71,29 +77,31 @@ class ConfigError extends Error {
71
77
  }
72
78
 
73
79
  function resolveEnvNumber(envPairs, envKey, cliValue, bounds) {
74
- // CLI 来源:cli.js 的 coerceValue 已做过边界检查,直接返回
80
+ // CLI source: cli.js coerceValue already checked the bounds, return as-is
75
81
  if (cliValue !== undefined) return cliValue;
76
- // env 文件优先,其次进程环境变量(与 resolveEnv 优先级一致,符合 SKILL.md 承诺);
77
- // env 来源的值需统一解析 + 边界校验,防止 env 绕过硬上限
82
+ // Env file first, then process env (same precedence as resolveEnv, per the
83
+ // SKILL.md contract); env-sourced values are parsed and bounds-checked
84
+ // uniformly so env cannot bypass hard limits
78
85
  const raw = envPairs[envKey] !== undefined ? envPairs[envKey] : process.env[envKey];
79
86
  if (raw === undefined) return undefined;
80
87
  const n = Number(raw);
81
88
  if (!Number.isInteger(n)) {
82
- throw new ConfigError(`环境变量 ${envKey}=${raw} 不是合法整数`);
89
+ throw new ConfigError(`Environment variable ${envKey}=${raw} is not a valid integer`);
83
90
  }
84
91
  if (bounds && bounds.min !== undefined && n < bounds.min) {
85
- throw new ConfigError(`环境变量 ${envKey}=${n} 低于最小值 ${bounds.min}`);
92
+ throw new ConfigError(`Environment variable ${envKey}=${n} is below the minimum ${bounds.min}`);
86
93
  }
87
94
  if (bounds && bounds.max !== undefined && n > bounds.max) {
88
- throw new ConfigError(`环境变量 ${envKey}=${n} 超过最大值 ${bounds.max}`);
95
+ throw new ConfigError(`Environment variable ${envKey}=${n} exceeds the maximum ${bounds.max}`);
89
96
  }
90
97
  return n;
91
98
  }
92
99
 
93
- // ── 连接目标脱敏 ──
100
+ // ── Connection target sanitizing ──
94
101
 
95
102
  /**
96
- * 生成脱敏的连接目标信息,绝不包含密码或含密码 URI
103
+ * Build sanitized connection target info; never includes the password or a
104
+ * password-bearing URI
97
105
  * @param {object} opts - { host, port, user, database }
98
106
  * @returns {string}
99
107
  */
@@ -104,14 +112,16 @@ function sanitizeTarget(opts) {
104
112
  return parts.join(', ');
105
113
  }
106
114
 
107
- // ── 安全错误格式化 ──
115
+ // ── Safe error formatting ──
108
116
 
109
- // 屏蔽 URL/连接串里内联的密码(://user:password@host → ://user:***@host)。
110
- // 用户名段用 * 以覆盖 redis 无用户名的标准形式(://:password@host),防御性脱敏
117
+ // Mask passwords inlined in URLs/connection strings (://user:password@host → ://user:***@host).
118
+ // The username segment uses * to also cover the standard no-username Redis
119
+ // form (://:password@host); defensive sanitizing
111
120
  const URL_CRED_PATTERN = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/\s:@]*:)[^/\s@]+(@)/g;
112
121
 
113
122
  /**
114
- * 取错误 message 首行,脱敏截断:屏蔽内联凭据 + 截断到 200 字符防意外泄露
123
+ * Take the first line of an error message, sanitized and truncated: mask
124
+ * inline credentials + cut at 200 chars to prevent accidental leaks
115
125
  * @param {string} s
116
126
  * @returns {string}
117
127
  */
@@ -121,27 +131,28 @@ function sanitizeErrorLine(s) {
121
131
  return masked.length > 200 ? masked.slice(0, 200) + '…' : masked;
122
132
  }
123
133
 
124
- // 连接/认证/握手阶段错误码 —— 归为「连接失败」,明确区分于查询执行错误
134
+ // Connection/auth/handshake error codes — classified as "connection failure",
135
+ // clearly distinct from query execution errors
125
136
  const CONNECTION_CODES = new Set([
126
- // Node 网络与系统错误
137
+ // Node network and system errors
127
138
  'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND',
128
139
  'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', 'ECONNABORTED', 'EDESTADDRREQ',
129
- // DNS 解析错误
140
+ // DNS resolution errors
130
141
  'EAI_AGAIN', 'EAI_NONAME', 'EAI_SERVICE', 'EAI_FAIL', 'EAI_BADFLAGS',
131
- // TLS 错误
142
+ // TLS errors
132
143
  'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_HAS_EXPIRED',
133
144
  'DEPTH_ZERO_SELF_SIGNED_CERT', 'ERR_TLS_INVALID_PROTOCOL_METHOD',
134
145
  'ERR_TLS_PROTOCOL_VERSION_CONFLICT',
135
- // mysql2 连接/握手/选库阶段
146
+ // mysql2 connection/handshake/db-selection stage
136
147
  'HANDSHAKE_ERROR', 'PROTOCOL_CONNECTION_LOST',
137
148
  'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR', 'PROTOCOL_SEQUENCE_TIMEOUT',
138
149
  'ER_ACCESS_DENIED_ERROR', 'ER_DBACCESS_DENIED_ERROR',
139
150
  'ER_BAD_DB_ERROR', 'ER_BAD_HOST_ERROR',
140
- // Redis 认证阶段
151
+ // Redis auth stage
141
152
  'WRONGPASS', 'NOAUTH',
142
153
  ]);
143
154
 
144
- // node-redis 命令执行阶段错误码(与连接阶段区分)
155
+ // node-redis command execution error codes (distinct from the connection stage)
145
156
  const REDIS_EXEC_CODES = new Set([
146
157
  'ERR', 'WRONGTYPE', 'WRONGINT', 'WRONGBIT', 'WRONGKEY',
147
158
  'EXECABORT', 'NOREPLICAS', 'MISCONF', 'BUSYGROUP', 'NOGROUP',
@@ -150,14 +161,18 @@ const REDIS_EXEC_CODES = new Set([
150
161
  ]);
151
162
 
152
163
  /**
153
- * 安全地格式化数据库错误,让排障 agent 能立刻看清真正的失败原因。
164
+ * Format a database error safely so a triaging agent can see the real failure
165
+ * at a glance.
154
166
  *
155
- * 关键安全前提:mysql2 的 ER_* / node-redis 的命令错误,其 message 来自
156
- * 数据库服务端,不含客户端凭据(服务端不感知客户端密码);Node 网络错误
157
- * message 形如 "connect ECONNREFUSED 127.0.0.1:3306" 也不含密码。因此按
158
- * code 分类后暴露 code + message 首行(已脱敏截断)是安全的,且能让 agent
159
- * 一次区分「连不上」还是「SQL 写错」,避免无效的二分排查。
160
- * 仅对「既无 code 也无 message」的极端异常给中性兜底,且不再误报成「连接失败」。
167
+ * Key security premise: messages of mysql2 ER_* / node-redis command errors
168
+ * come from the database server and contain no client credentials (the server
169
+ * never sees the client password); Node network errors such as
170
+ * "connect ECONNREFUSED 127.0.0.1:3306" contain no password either. So
171
+ * exposing the code plus the sanitized first message line, classified by
172
+ * code, is safe and lets the agent tell "cannot connect" from "bad SQL" in
173
+ * one step, avoiding pointless bisection. Only pathological errors with
174
+ * neither code nor message get a neutral fallback, no longer misreported as
175
+ * "connection failure".
161
176
  * @param {Error} err
162
177
  * @returns {string}
163
178
  */
@@ -168,29 +183,31 @@ function safeMessage(err) {
168
183
  if (code) {
169
184
  const codeStr = String(code);
170
185
  if (CONNECTION_CODES.has(codeStr)) {
171
- return `连接失败 [${codeStr}]: ${line}`;
186
+ return `Connection failed [${codeStr}]: ${line}`;
172
187
  }
173
- // mysql2 服务端执行错误:ER_BAD_FIELD_ERROR(1054)/ER_PARSE_ERROR/ER_NO_SUCH_TABLE 等
188
+ // mysql2 server-side execution errors: ER_BAD_FIELD_ERROR(1054)/ER_PARSE_ERROR/ER_NO_SUCH_TABLE etc.
174
189
  if (/^ER_/.test(codeStr)) {
175
- return `查询执行错误 [${codeStr}]: ${line}`;
190
+ return `Query execution error [${codeStr}]: ${line}`;
176
191
  }
177
- // node-redis 命令执行错误
192
+ // node-redis command execution errors
178
193
  if (REDIS_EXEC_CODES.has(codeStr)) {
179
- return `查询执行错误 [${codeStr}]: ${line}`;
194
+ return `Query execution error [${codeStr}]: ${line}`;
180
195
  }
181
- // 未知 code:仍暴露 code + 首行(已脱敏截断),比「详情已隐藏」更利于排障
196
+ // Unknown code: still expose code + first line (sanitized and
197
+ // truncated); more useful for triage than "details hidden"
182
198
  return `[${codeStr}] ${line}`;
183
199
  }
184
200
 
185
- // 无 code:无法分类,给中性措辞,不再误导成「连接失败」
201
+ // No code: cannot classify, use neutral wording instead of a misleading
202
+ // "connection failure"
186
203
  const name = err.name || 'Error';
187
- return `${name}: ${line || '(无错误信息,请检查 SQL/命令与连接参数)'}`;
204
+ return `${name}: ${line || '(no error message; check the SQL/command and connection parameters)'}`;
188
205
  }
189
206
 
190
- // ── TLS 选项构建 ──
207
+ // ── TLS options ──
191
208
 
192
209
  /**
193
- * 从 ca-file 路径构建 TLS 选项
210
+ * Build TLS options from a ca-file path
194
211
  * @param {string|undefined} caFile
195
212
  * @returns {object|undefined}
196
213
  */
@@ -198,7 +215,7 @@ function buildTlsOptions(caFile) {
198
215
  if (!caFile) return undefined;
199
216
  const resolved = path.resolve(caFile);
200
217
  if (!fs.existsSync(resolved)) {
201
- throw new Error(`CA 证书文件不存在: ${resolved}`);
218
+ throw new Error(`CA certificate file not found: ${resolved}`);
202
219
  }
203
220
  const ca = fs.readFileSync(resolved);
204
221
  return { ca, rejectUnauthorized: true };