@quecue/dev-notify 0.1.3 → 0.1.5

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/dist/cli.js CHANGED
@@ -4,9 +4,9 @@ import { readFile } from 'node:fs/promises';
4
4
  import { realpathSync } from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
- import { appPaths, ConfigError, createConfig, decodeUtf8, loadConfig, ready } from './config.js';
7
+ import { appPaths, ConfigError, createConfig, decodeUtf8, loadConfig, readConfigLanguage, ready } from './config.js';
8
8
  import { deliver, makePlan } from './channels.js';
9
- import { wizard, clearCredentials } from './setup.js';
9
+ import { wizard, clearCredentials, setConfigLanguage } from './setup.js';
10
10
  import { terminalPrompts, readText, Cancelled } from './prompts.js';
11
11
  import { CredentialError } from './credentials.js';
12
12
  import { claudeCodeStatus, claudeSettingsPath, configureClaudeCode } from './claude-code.js';
@@ -15,35 +15,29 @@ import { accessReplies, configureReplies, disableReplies } from './reply-state.j
15
15
  import { startBridge, stopBridge, bridgeStatus, runBridge } from './reply-bridge.js';
16
16
  import { updatePackage } from './update.js';
17
17
  import { postUpdate } from './maintenance.js';
18
- export const VERSION = '0.1.3';
19
- const HELP = `dev-notify ${VERSION} — 跨平台开发任务通知
20
-
21
- 命令:
22
- send --title 标题 [--content 正文 | --content-file 文件 | --stdin]
23
- [--channel all|telegram|feishu] [--level info|success|warn|error]
24
- [--footer 辅助信息] [--dry-run]
25
- setup [--no-test] [--no-hooks]
26
- 配置渠道及系统凭据;默认同时发送测试并安装
27
- Claude Code 通知 hooks,一次确认完成
28
- setup --template [--yes] 只创建无密钥模板;不会覆盖已有配置,也不会安装 hooks
29
- credentials clear 移除当前 Node 凭据引用和条目(需要确认)
30
- paths 显示配置路径(不会写入文件)
31
- doctor / status 离线检查(不会发送消息)
32
- update [--check] [--yes] [--no-post]
33
- 检查并更新到 npm 最新版本,并维护 hooks 和桥接
34
- integration list 列出可用集成
35
- integration claude-code install / status / remove
36
- 单独安装、查询或移除用户级 hooks(install/remove 可用 --yes)
37
- reply setup 授权可信的 Telegram 私聊用户
38
- reply start / status / stop
39
- 启动、查询或停止回复桥接
40
- reply disable 撤销远程回复授权并停止桥接(需要确认)
41
- reply run 前台运行桥接(排错用,不启用系统自启动)
42
-
43
- 全局选项:--config 路径、--help、--version
44
- 环境变量:TELEGRAM_BOT_TOKEN、TELEGRAM_CHAT_ID / TELEGRAM_CHAT_IDS、
45
- FEISHU_WEBHOOK、FEISHU_SECRET;显式存在时优先。详见 README。
46
- 系统凭据保存在 Windows Credential Manager / macOS Keychain;不会写入明文密钥。`;
18
+ import { localeName, parseLocale, resolveLocale, setLocale, t } from './i18n.js';
19
+ export const VERSION = '0.1.5';
20
+ const helpText = () => t('cli.help', { version: VERSION });
21
+ function rawLang(argv) {
22
+ for (let i = 0; i < argv.length; i++) {
23
+ if (argv[i] === '--lang')
24
+ return argv[i + 1];
25
+ if (argv[i].startsWith('--lang='))
26
+ return argv[i].slice('--lang='.length);
27
+ }
28
+ return undefined;
29
+ }
30
+ function envLanguage(env) {
31
+ return typeof env.DEV_NOTIFY_LANG === 'string' && env.DEV_NOTIFY_LANG !== '' ? env.DEV_NOTIFY_LANG : undefined;
32
+ }
33
+ async function configuredLanguage(file, env) {
34
+ try {
35
+ return await readConfigLanguage(file ?? appPaths(process.platform, env).config);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
47
41
  const defaults = {
48
42
  env: process.env,
49
43
  out: text => console.log(text),
@@ -53,19 +47,20 @@ const defaults = {
53
47
  hookStdin: () => readHookInput(),
54
48
  confirm: async () => {
55
49
  if (!process.stdin.isTTY)
56
- throw new ConfigError('当前不是交互终端。自动化场景请使用 setup --template --yes。');
57
- return /^(y|yes)$/i.test(await readText('确认创建模板?[y/N] '));
50
+ throw new ConfigError(t('cli.nonInteractive'));
51
+ return /^(y|yes)$/i.test(await readText(t('cli.confirmTemplate')));
58
52
  },
59
53
  send: deliver,
60
54
  };
61
55
  export async function main(argv = process.argv.slice(2), overrides = {}) {
62
56
  const context = { ...defaults, ...overrides };
57
+ setLocale(parseLocale(rawLang(argv)) ?? parseLocale(envLanguage(context.env)) ?? 'zh');
63
58
  const isHook = argv[0] === 'integration' && argv[1] === 'claude-code' && argv[2] === 'hook';
64
59
  try {
65
60
  let parsed;
66
61
  try {
67
62
  parsed = parseArgs({ args: argv, allowPositionals: true, strict: true, options: {
68
- help: { type: 'boolean', short: 'h' }, version: { type: 'boolean' }, config: { type: 'string' },
63
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean' }, config: { type: 'string' }, lang: { type: 'string' },
69
64
  title: { type: 'string' }, content: { type: 'string' }, 'content-file': { type: 'string' }, stdin: { type: 'boolean' },
70
65
  channel: { type: 'string' }, level: { type: 'string' }, footer: { type: 'string' }, 'dry-run': { type: 'boolean' },
71
66
  template: { type: 'boolean' }, yes: { type: 'boolean' },
@@ -73,11 +68,21 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
73
68
  } });
74
69
  }
75
70
  catch {
76
- throw new ConfigError('参数无效,请运行 --help 查看用法。');
71
+ throw new ConfigError(t('cli.invalidArgs'));
77
72
  }
78
73
  const { values, positionals } = parsed;
74
+ if (values.lang !== undefined && values.lang !== '' && !parseLocale(values.lang))
75
+ throw new ConfigError(t('cli.invalidLanguage', { value: values.lang }));
76
+ if (values.lang === undefined && envLanguage(context.env) !== undefined && !parseLocale(envLanguage(context.env))) {
77
+ throw new ConfigError(t('cli.invalidLanguage', { value: envLanguage(context.env) }));
78
+ }
79
79
  if (values.help) {
80
- context.out(HELP);
80
+ if (values.lang === undefined && envLanguage(context.env) === undefined) {
81
+ const configured = await configuredLanguage(values.config ? path.resolve(values.config) : undefined, context.env);
82
+ if (configured)
83
+ setLocale(configured);
84
+ }
85
+ context.out(helpText());
81
86
  return 0;
82
87
  }
83
88
  if (values.version) {
@@ -87,13 +92,33 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
87
92
  const [command, action, integrationAction] = positionals;
88
93
  const allowed = {
89
94
  send: ['title', 'content', 'content-file', 'stdin', 'channel', 'level', 'footer', 'dry-run'],
90
- setup: ['template', 'yes', 'no-test', 'no-hooks'], paths: [], doctor: [], status: [], integration: ['yes'], credentials: [], reply: [], update: ['check', 'yes', 'no-post'],
95
+ setup: ['template', 'yes', 'no-test', 'no-hooks'], paths: [], doctor: [], status: [], integration: ['yes'], credentials: [], reply: [], update: ['check', 'yes', 'no-post'], language: [],
91
96
  };
92
- if (!command || !Object.hasOwn(allowed, command) || positionals.length > (command === 'integration' ? 3 : command === 'credentials' || command === 'reply' ? 2 : 1)
93
- || Object.keys(values).some(key => key !== 'config' && !allowed[command].includes(key)))
94
- throw new ConfigError('命令或选项组合不正确,请运行 --help 查看用法。');
97
+ const maxPositionals = command === 'integration' ? 3 : command === 'credentials' || command === 'reply' || command === 'language' ? 2 : 1;
98
+ if (!command || !Object.hasOwn(allowed, command) || positionals.length > maxPositionals
99
+ || Object.keys(values).some(key => key !== 'config' && key !== 'lang' && !allowed[command].includes(key)))
100
+ throw new ConfigError(t('cli.invalidCommand'));
95
101
  const paths = appPaths(process.platform, context.env);
96
102
  const file = values.config ? path.resolve(values.config) : paths.config;
103
+ setLocale(resolveLocale(values.lang, context.env, await readConfigLanguage(file)));
104
+ if (command === 'language') {
105
+ const requested = positionals[1];
106
+ if (requested !== undefined) {
107
+ const target = parseLocale(requested);
108
+ if (!target)
109
+ throw new ConfigError(t('cli.invalidLanguage', { value: requested }));
110
+ setLocale(target);
111
+ const changed = await setConfigLanguage(file, target, context.out);
112
+ context.out(t(changed ? 'cli.languageChanged' : 'cli.languageAlready', { language: localeName(target), code: target }));
113
+ }
114
+ else {
115
+ const effective = resolveLocale(values.lang, context.env, await readConfigLanguage(file));
116
+ setLocale(effective);
117
+ context.out(t('cli.languageCurrent', { language: localeName(effective), code: effective }));
118
+ context.out(t('cli.languageHint'));
119
+ }
120
+ return 0;
121
+ }
97
122
  if (command === 'paths') {
98
123
  context.out(JSON.stringify({ config: file, data: paths.data }, null, 2));
99
124
  return 0;
@@ -108,21 +133,21 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
108
133
  }
109
134
  if (command === 'integration') {
110
135
  if (action === 'list' && !integrationAction) {
111
- context.out('claude-code:支持通知、Telegram 回复和结构化问题交互。通知 hooks 与回复权限分开,默认只发送通知,不接收远程输入。');
136
+ context.out(t('cli.integrationList'));
112
137
  return 0;
113
138
  }
114
139
  if (action !== 'claude-code' || !['install', 'status', 'remove', 'hook'].includes(integrationAction))
115
- throw new ConfigError('请使用 integration list,或 integration claude-code install / status / remove。');
140
+ throw new ConfigError(t('cli.integrationUsage'));
116
141
  if (integrationAction === 'hook') {
117
142
  await dispatchClaudeHook(await context.hookStdin(), file, { ...context, response: value => context.out(JSON.stringify(value)) });
118
143
  return 0;
119
144
  }
120
145
  if (integrationAction === 'status') {
121
146
  const status = await claudeCodeStatus({ env: context.env });
122
- context.out(`Claude Code 用户级设置:${status.file}`);
123
- context.out(`本工具 hooks:Stop ${status.stop} 个,Notification ${status.notification} 个,会话生命周期 ${status.lifecycle}/3 个,交互及结果确认 ${status.interaction}/5 个;其他处理器 ${status.others} 个。`);
124
- context.out(status.disabled ? '当前文件启用了 disableAllHooks,安装后 hooks 不会生效;请自行解除禁用。'
125
- : '这里只检查用户级文件。是否实际加载还受项目和托管策略影响,请在 Claude Code 的 /hooks 中确认。');
147
+ context.out(t('cli.integrationStatusFile', { file: status.file }));
148
+ context.out(t('cli.integrationStatusCounts', { stop: status.stop, notification: status.notification,
149
+ lifecycle: status.lifecycle, interaction: status.interaction, others: status.others }));
150
+ context.out(status.disabled ? t('cli.integrationDisabled') : t('cli.integrationStatusNote'));
126
151
  return status.stop === 1 && status.notification === 1 && status.lifecycle === 3 && status.interaction === 5 && !status.disabled ? 0 : 1;
127
152
  }
128
153
  const prompts = context.prompts ?? (values.yes
@@ -131,36 +156,36 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
131
156
  if (integrationAction === 'install') {
132
157
  const config = await loadConfig(file, true, context.env);
133
158
  if (!ready(config, 'telegram') && !ready(config, 'feishu'))
134
- throw new ConfigError('请先运行 setup 配置至少一个通知渠道,再安装 Claude Code hooks。');
159
+ throw new ConfigError(t('cli.setupChannelFirst'));
135
160
  }
136
161
  return await configureClaudeCode(file, integrationAction, prompts, { env: context.env, confirm: values.yes ? false : undefined }) ? 0 : 130;
137
162
  }
138
163
  if (command === 'reply') {
139
164
  if (!['setup', 'start', 'status', 'stop', 'disable', 'run'].includes(action))
140
- throw new ConfigError('请使用 reply setup / start / status / stop / disable / run。');
165
+ throw new ConfigError(t('cli.replyUsage'));
141
166
  if (action === 'setup') {
142
167
  const saved = await configureReplies(file, context.prompts ?? terminalPrompts(), context.env);
143
168
  if (saved)
144
- context.out('授权已保存。请确认 hooks 已安装,然后运行 reply start。旧消息的回复映射不会保留。');
169
+ context.out(t('cli.replySaved'));
145
170
  return saved ? 0 : 130;
146
171
  }
147
172
  if (action === 'disable') {
148
173
  if (!await disableReplies(file, context.prompts ?? terminalPrompts()))
149
174
  return 130;
150
175
  const stopped = await stopBridge(file);
151
- context.out(stopped ? '桥接已停止或未运行。' : '已撤销授权并请求停止。正在进行中的调用无法撤回。');
176
+ context.out(stopped ? t('cli.bridgeStopped') : t('cli.replyDisabled'));
152
177
  return 0;
153
178
  }
154
179
  if (action === 'stop') {
155
180
  const stopped = await stopBridge(file);
156
- context.out(stopped ? '桥接已停止或未运行。授权仍然保留,下次事件可以再次启动;如需彻底关闭,请运行 reply disable。'
157
- : '已发出停止请求,桥接正在退出。可运行 reply status 查看状态。');
181
+ context.out(stopped ? t('cli.stopStopped') : t('cli.stopRequested'));
158
182
  return stopped ? 0 : 1;
159
183
  }
160
184
  if (action === 'status') {
161
185
  const access = await accessReplies(file, context.env), status = await bridgeStatus(file);
162
- context.out(access ? `远程回复:已授权 ${access.policy.users.length} 个私聊用户;工具权限逐次确认${access.policy.permissions ? '已开启' : '未开启'}。` : '远程回复:尚未启用。');
163
- context.out(status.ready ? '桥接:已就绪。' : status.running ? '桥接:进程存在,但还没有就绪。' : '桥接:未运行。');
186
+ context.out(access ? t('cli.replyStatusAccess', { users: access.policy.users.length,
187
+ state: t(access.policy.permissions ? 'cli.replyPermissionOn' : 'cli.replyPermissionOff') }) : t('cli.replyStatusNoAccess'));
188
+ context.out(status.ready ? t('cli.bridgeReady') : status.running ? t('cli.bridgeStarting') : t('cli.bridgeNotRunning'));
164
189
  if (status.error)
165
190
  context.err(status.error);
166
191
  return access && status.ready ? 0 : 1;
@@ -170,33 +195,33 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
170
195
  return 0;
171
196
  }
172
197
  const ready = await startBridge(file, context.env);
173
- context.out(ready ? '回复桥接已就绪。请从新的任务通知进入。' : '桥接还没有就绪。请运行 reply status 或 reply run 查看原因;如果旧版桥接在运行,请先停用它。');
198
+ context.out(ready ? t('cli.replyStartReady') : t('cli.replyStartNotReady'));
174
199
  return ready ? 0 : 1;
175
200
  }
176
201
  if (command === 'credentials') {
177
202
  if (action !== 'clear')
178
- throw new ConfigError('credentials 目前只支持 clear,并且需要在交互终端中确认。');
203
+ throw new ConfigError(t('cli.credentialsUsage'));
179
204
  return await clearCredentials(file, context.prompts ?? terminalPrompts()) ? 0 : 130;
180
205
  }
181
206
  if (command === 'setup') {
182
207
  if (!values.template) {
183
208
  if (values.yes)
184
- throw new ConfigError('--yes 只能和 --template 一起使用;保存密钥前仍然需要确认。');
209
+ throw new ConfigError(t('cli.yesTemplateOnly'));
185
210
  const prompts = context.prompts ?? terminalPrompts();
186
211
  const notes = [];
187
212
  const test = !values['no-test'];
188
213
  if (test)
189
- notes.push('保存后会向最终生效的接收目标发送一条固定内容的测试通知。');
214
+ notes.push(t('cli.setupNoteTest'));
190
215
  let hooks = !values['no-hooks'];
191
216
  if (hooks) {
192
217
  try {
193
- notes.push(`Claude Code 用户级设置:${claudeSettingsPath(context.env)}`);
194
- notes.push('会为该设置文件对应的所有项目安装通知 hooks。最终回复正文可能包含敏感内容,将发送到配置实际生效的渠道。');
195
- notes.push('同时安装会话生命周期和结构化问题 hooks。远程输入只有在运行 reply setup 授权后才会开启。');
218
+ notes.push(t('cli.setupNoteHooksPath', { path: claudeSettingsPath(context.env) }));
219
+ notes.push(t('cli.setupNoteHooksScope'));
220
+ notes.push(t('cli.setupNoteHooksLifecycle'));
196
221
  }
197
222
  catch (error) {
198
223
  hooks = false;
199
- context.out(error instanceof ConfigError ? `跳过 Claude Code hooks:${error.message}` : '跳过 Claude Code hooks:没有找到用户级设置路径。');
224
+ context.out(error instanceof ConfigError ? t('cli.skipHooks', { reason: error.message }) : t('cli.skipHooksNoPath'));
200
225
  }
201
226
  }
202
227
  let integrationFailed = false;
@@ -208,9 +233,9 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
208
233
  if (!ready(config, channel))
209
234
  continue;
210
235
  count++;
211
- const plan = makePlan(channel, { title: 'dev-notify 配置测试', content: '这是一条由你确认发送的测试通知。', footer: '', level: 'info' }, config);
236
+ const plan = makePlan(channel, { title: t('cli.testTitle'), content: t('cli.testContent'), footer: '', level: 'info' }, config);
212
237
  const result = await context.send(plan, config);
213
- context.out(`${channel}:测试已发送 ${result.sent}/${result.planned} 条`);
238
+ context.out(t('cli.testSent', { channel, sent: result.sent, planned: result.planned }));
214
239
  ok = ok && result.ok;
215
240
  }
216
241
  return count > 0 && ok;
@@ -229,31 +254,31 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
229
254
  return 130;
230
255
  return integrationFailed ? 1 : 0;
231
256
  }
232
- context.out(`将创建无密钥模板:${file}。不会联网,也不会修改 hooks。`);
257
+ context.out(t('cli.templateWillCreate', { file }));
233
258
  if (!values.yes && !await context.confirm()) {
234
- context.out('已取消,没有写入。');
259
+ context.out(t('cli.templateCancelled'));
235
260
  return 130;
236
261
  }
237
262
  await createConfig(file);
238
- context.out('模板已创建。请配置环境凭据和接收人。');
263
+ context.out(t('cli.templateCreated'));
239
264
  return 0;
240
265
  }
241
266
  const config = await loadConfig(file, values.config !== undefined, context.env);
242
267
  if (command === 'doctor' || command === 'status') {
243
- context.out(`配置路径: ${file}`);
268
+ context.out(t('cli.configPath', { file }));
244
269
  for (const channel of ['telegram', 'feishu'])
245
- context.out(`${channel}:${ready(config, channel) ? '配置完整(尚未验证网络)' : '未启用或配置不完整'}`);
246
- context.out('凭据:优先使用环境变量,其次系统凭据;本检查不联网,系统可能弹出授权提示。');
270
+ context.out(t('cli.channelStatus', { channel, status: t(ready(config, channel) ? 'cli.channelReady' : 'cli.channelNotReadyStatus') }));
271
+ context.out(t('cli.credentialsNote'));
247
272
  return ready(config, 'telegram') || ready(config, 'feishu') ? 0 : 1;
248
273
  }
249
274
  if (!values.title?.trim())
250
- throw new ConfigError('send 需要提供非空的 --title。');
275
+ throw new ConfigError(t('cli.sendTitle'));
251
276
  if ([values.content !== undefined, values['content-file'] !== undefined, !!values.stdin].filter(Boolean).length > 1)
252
- throw new ConfigError('--content、--content-file 和 --stdin 只能选择一个。');
277
+ throw new ConfigError(t('cli.sendSources'));
253
278
  const level = values.level ?? 'info';
254
279
  const channel = values.channel ?? 'all';
255
280
  if (!['info', 'success', 'warn', 'error'].includes(level) || !['all', 'telegram', 'feishu'].includes(channel))
256
- throw new ConfigError('渠道或通知级别不正确。');
281
+ throw new ConfigError(t('cli.channelLevel'));
257
282
  let content = values.content ?? values.title;
258
283
  if (values['content-file'] !== undefined)
259
284
  content = decodeUtf8(await readFile(values['content-file']));
@@ -265,7 +290,7 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
265
290
  for (const name of selected) {
266
291
  if (!ready(config, name)) {
267
292
  if (channel !== 'all')
268
- throw new ConfigError('这个渠道未启用,或配置不完整。');
293
+ throw new ConfigError(t('cli.channelNotReady'));
269
294
  continue;
270
295
  }
271
296
  planned++;
@@ -274,31 +299,31 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
274
299
  plan = makePlan(name, message, config);
275
300
  }
276
301
  catch {
277
- context.err(`${name}:无法安全分段。请缩短标题或底部信息,或检查正文格式。`);
302
+ context.err(t('cli.segmentationFailed', { channel: name }));
278
303
  failed = true;
279
304
  continue;
280
305
  }
281
306
  if (values['dry-run']) {
282
- context.out(`${name}:接收目标 ${plan.recipients},预计请求 ${plan.items.length} 条(dry-run,没有实际发送)`);
307
+ context.out(t('cli.dryRun', { channel: name, recipients: plan.recipients, items: plan.items.length }));
283
308
  continue;
284
309
  }
285
310
  const result = await context.send(plan, config);
286
- context.out(`${name}:已发送 ${result.sent}/${result.planned} 条`);
311
+ context.out(t('cli.sent', { channel: name, sent: result.sent, planned: result.planned }));
287
312
  if (!result.ok) {
288
313
  failed = true;
289
- context.err(result.error ?? '发送失败。');
314
+ context.err(result.error ?? t('channels.sendFailed'));
290
315
  }
291
316
  }
292
317
  if (!planned)
293
- throw new ConfigError('没有可用渠道,请先配置环境凭据和接收人。');
318
+ throw new ConfigError(t('cli.noChannels'));
294
319
  return failed ? 1 : 0;
295
320
  }
296
321
  catch (error) {
297
322
  if (error instanceof Cancelled) {
298
- context.out('已取消。');
323
+ context.out(t('common.cancelled'));
299
324
  return isHook ? 0 : 130;
300
325
  }
301
- context.err(error instanceof ConfigError || error instanceof CredentialError ? error.message : '操作失败。请检查文件、权限或输入;为避免泄露密钥,这里不显示底层异常。');
326
+ context.err(error instanceof ConfigError || error instanceof CredentialError ? error.message : t('common.failure'));
302
327
  // Stop hook 的退出码 2 会要求 Claude 继续;通知失败只能报告,不能阻塞或触发重试循环。
303
328
  return isHook ? 0 : 2;
304
329
  }
package/dist/config.js CHANGED
@@ -2,10 +2,12 @@ import { readFile, mkdir, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { resolveReference } from './credentials.js';
5
+ import { getLocale, t } from './i18n.js';
5
6
  export class ConfigError extends Error {
6
7
  }
7
8
  export const example = {
8
9
  version: 1,
10
+ language: 'zh',
9
11
  telegram: { enabled: true, chat_ids: [], token_env: 'TELEGRAM_BOT_TOKEN' },
10
12
  feishu: { enabled: true, webhook_env: 'FEISHU_WEBHOOK', secret_env: 'FEISHU_SECRET' },
11
13
  };
@@ -13,7 +15,7 @@ export function appPaths(platform = process.platform, env = process.env, home) {
13
15
  const join = platform === 'win32' ? path.win32 : path.posix;
14
16
  if (env.DEV_NOTIFY_HOME) {
15
17
  if (!join.isAbsolute(env.DEV_NOTIFY_HOME))
16
- throw new ConfigError('DEV_NOTIFY_HOME 需要是绝对路径。');
18
+ throw new ConfigError(t('config.homeAbsolute'));
17
19
  return { config: join.join(env.DEV_NOTIFY_HOME, 'config', 'config.json'), data: join.join(env.DEV_NOTIFY_HOME, 'data') };
18
20
  }
19
21
  home ??= homedir();
@@ -31,36 +33,39 @@ export function appPaths(platform = process.platform, env = process.env, home) {
31
33
  }
32
34
  export function object(value, allowed, name) {
33
35
  if (!value || typeof value !== 'object' || Array.isArray(value))
34
- throw new ConfigError(`${name} 需要是 JSON 对象。`);
36
+ throw new ConfigError(t('config.objectRequired', { name }));
35
37
  const result = value;
36
38
  if (Object.keys(result).some(key => !allowed.includes(key)))
37
- throw new ConfigError(`${name} 包含不支持的字段;不能直接保存明文密钥。`);
39
+ throw new ConfigError(t('config.objectUnknown', { name }));
38
40
  return result;
39
41
  }
40
42
  function enabled(section) {
41
43
  if (section.enabled === undefined)
42
44
  return true;
43
45
  if (typeof section.enabled !== 'boolean')
44
- throw new ConfigError('enabled 需要是布尔值。');
46
+ throw new ConfigError(t('config.enabledBoolean'));
45
47
  return section.enabled;
46
48
  }
47
49
  function secret(section, field, fallback, env, stored) {
48
50
  const name = section[field] ?? fallback;
49
51
  if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
50
- throw new ConfigError('凭据引用需要是合法的环境变量名称。');
52
+ throw new ConfigError(t('config.credentialEnvName'));
51
53
  if (Object.hasOwn(env, name))
52
54
  return (env[name] ?? '').trim();
53
55
  return enabled(section) ? stored().trim() : '';
54
56
  }
55
57
  export function parseConfig(input, env = process.env, resolver = resolveReference) {
56
- const data = object(input, ['version', 'telegram', 'feishu', 'credentials'], '配置');
58
+ const data = object(input, ['version', 'language', 'telegram', 'feishu', 'credentials'], t('config.rootName'));
57
59
  if ((data.version ?? 1) !== 1)
58
- throw new ConfigError('不支持这个配置版本。');
60
+ throw new ConfigError(t('config.unsupportedVersion'));
61
+ const language = data.language ?? 'zh';
62
+ if (language !== 'zh' && language !== 'en')
63
+ throw new ConfigError(t('config.invalidLanguage'));
59
64
  let reference;
60
65
  if (data.credentials !== undefined) {
61
66
  const value = object(data.credentials, ['backend', 'id'], 'credentials');
62
67
  if (!['system', 'keyring'].includes(String(value.backend)) || typeof value.id !== 'string' || !/^[a-f0-9]{32}$/.test(value.id))
63
- throw new ConfigError('系统凭据引用无效。');
68
+ throw new ConfigError(t('config.invalidReference'));
64
69
  reference = value;
65
70
  }
66
71
  let cached;
@@ -78,18 +83,18 @@ export function parseConfig(input, env = process.env, resolver = resolveReferenc
78
83
  ids = `${env.TELEGRAM_CHAT_ID ?? ''},${env.TELEGRAM_CHAT_IDS ?? ''}`.split(',').map(v => v.trim()).filter(Boolean);
79
84
  }
80
85
  if (!Array.isArray(ids))
81
- throw new ConfigError('telegram.chat_ids 需要是数组。');
86
+ throw new ConfigError(t('config.chatIdsArray'));
82
87
  const chatIds = [...new Set(ids.map(value => {
83
88
  if (typeof value !== 'string' && !(typeof value === 'number' && Number.isSafeInteger(value)))
84
- throw new ConfigError('接收人需要使用数字 chat_id;大整数请写成字符串。');
89
+ throw new ConfigError(t('config.chatIdNumeric'));
85
90
  const id = String(value).trim();
86
91
  if (!/^-?[1-9][0-9]*$/.test(id))
87
- throw new ConfigError('接收人需要是非零的数字 chat_id。');
92
+ throw new ConfigError(t('config.chatIdNonZero'));
88
93
  return id;
89
94
  }))];
90
95
  const token = secret(tg, 'token_env', 'TELEGRAM_BOT_TOKEN', env, () => stored('telegram_token'));
91
96
  if (token && !/^[0-9]+:[A-Za-z0-9_-]+$/.test(token))
92
- throw new ConfigError('Telegram Token 格式不正确。');
97
+ throw new ConfigError(t('config.telegramToken'));
93
98
  const webhook = secret(fs, 'webhook_env', 'FEISHU_WEBHOOK', env, () => stored('feishu_webhook'));
94
99
  if (webhook) {
95
100
  let valid = false;
@@ -99,9 +104,9 @@ export function parseConfig(input, env = process.env, resolver = resolveReferenc
99
104
  }
100
105
  catch { /* 不输出含秘密的 URL 异常。 */ }
101
106
  if (!valid)
102
- throw new ConfigError('飞书 webhook 需要是 open.feishu.cn 的 HTTPS 自定义机器人地址。');
107
+ throw new ConfigError(t('config.feishuWebhook'));
103
108
  }
104
- return { telegram: { enabled: tgEnabled, token, chatIds }, feishu: { enabled: fsEnabled, webhook,
109
+ return { language, telegram: { enabled: tgEnabled, token, chatIds }, feishu: { enabled: fsEnabled, webhook,
105
110
  secret: secret(fs, 'secret_env', 'FEISHU_SECRET', env, () => stored('feishu_secret')) } };
106
111
  }
107
112
  export function ready(config, channel) {
@@ -113,9 +118,21 @@ export function decodeUtf8(data) {
113
118
  return new TextDecoder('utf-8', { fatal: true }).decode(data);
114
119
  }
115
120
  catch {
116
- throw new ConfigError('输入需要是有效的 UTF-8 文本。');
121
+ throw new ConfigError(t('config.utf8'));
117
122
  }
118
123
  }
124
+ export async function readConfigLanguage(file) {
125
+ try {
126
+ const data = JSON.parse(decodeUtf8(await readFile(file)));
127
+ return parseLocaleFromConfig(data?.language);
128
+ }
129
+ catch {
130
+ return undefined;
131
+ }
132
+ }
133
+ function parseLocaleFromConfig(value) {
134
+ return value === 'zh' || value === 'en' ? value : undefined;
135
+ }
119
136
  export async function loadConfig(file, required = false, env = process.env) {
120
137
  let input = {};
121
138
  try {
@@ -124,18 +141,18 @@ export async function loadConfig(file, required = false, env = process.env) {
124
141
  catch (error) {
125
142
  if (error.code === 'ENOENT' && !required)
126
143
  return parseConfig({}, env);
127
- throw new ConfigError('配置文件缺失、无法读取,或不是合法的 UTF-8 JSON。');
144
+ throw new ConfigError(t('config.unreadable'));
128
145
  }
129
146
  return parseConfig(input, env);
130
147
  }
131
- export async function createConfig(file) {
148
+ export async function createConfig(file, language = getLocale()) {
132
149
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
133
150
  try {
134
- await writeFile(file, JSON.stringify(example, null, 2) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 });
151
+ await writeFile(file, JSON.stringify({ ...example, language }, null, 2) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 });
135
152
  }
136
153
  catch (error) {
137
154
  if (error.code === 'EEXIST')
138
- throw new ConfigError('配置已经存在,没有覆盖。Node 版不会修改旧版原型的配置。');
139
- throw new ConfigError('无法创建配置,请检查路径和权限。');
155
+ throw new ConfigError(t('config.exists'));
156
+ throw new ConfigError(t('config.createFailed'));
140
157
  }
141
158
  }
@@ -1,12 +1,13 @@
1
1
  // 只访问本工具的随机引用条目,不枚举系统凭据,不通过子进程传递秘密。
2
2
  import { createRequire } from 'node:module';
3
+ import { t } from './i18n.js';
3
4
  export const SERVICE = 'dev-notify.node.credentials.v1';
4
5
  export class CredentialError extends Error {
5
6
  }
6
7
  const fields = ['telegram_token', 'feishu_webhook', 'feishu_secret'];
7
8
  export function validateId(id) {
8
9
  if (typeof id !== 'string' || !/^[0-9a-f]{32}$/.test(id))
9
- throw new CredentialError('系统凭据引用无效。');
10
+ throw new CredentialError(t('credentials.invalidReference'));
10
11
  }
11
12
  export function decodeBundle(raw) {
12
13
  try {
@@ -18,7 +19,7 @@ export function decodeBundle(raw) {
18
19
  return result;
19
20
  }
20
21
  catch {
21
- throw new CredentialError('系统凭据数据无效,请重新运行 setup。');
22
+ throw new CredentialError(t('credentials.invalidBundle'));
22
23
  }
23
24
  }
24
25
  export function encodeBundle(values) {
@@ -26,7 +27,7 @@ export function encodeBundle(values) {
26
27
  decodeBundle(encoded);
27
28
  // Windows Credential Manager 通用凭据 blob 上限 2560 字节,跨平台采用保守值。
28
29
  if (encoded.byteLength > 2400)
29
- throw new CredentialError('凭据总长度超过 2400 字节,请检查输入;没有保存。');
30
+ throw new CredentialError(t('credentials.tooLong'));
30
31
  return encoded;
31
32
  }
32
33
  export class KeyringStore {
@@ -42,7 +43,7 @@ export class KeyringStore {
42
43
  return raw === null ? null : decodeBundle(Uint8Array.from(raw));
43
44
  }
44
45
  catch {
45
- throw new CredentialError('无法读取系统凭据,或内容无效。请解锁系统凭据存储并允许访问,或重新运行 setup 配置。');
46
+ throw new CredentialError(t('credentials.readFailed'));
46
47
  }
47
48
  }
48
49
  create(id, values) {
@@ -52,12 +53,12 @@ export class KeyringStore {
52
53
  try {
53
54
  entry = this.entry(id);
54
55
  if (entry.getSecret() !== null)
55
- throw new CredentialError('这个随机凭据引用已经存在,没有覆盖,请重试。');
56
+ throw new CredentialError(t('credentials.exists'));
56
57
  }
57
58
  catch (error) {
58
59
  if (error instanceof CredentialError)
59
60
  throw error;
60
- throw new CredentialError('无法访问系统凭据存储,请检查系统权限。');
61
+ throw new CredentialError(t('credentials.accessFailed'));
61
62
  }
62
63
  try {
63
64
  entry.setSecret(encoded);
@@ -68,9 +69,9 @@ export class KeyringStore {
68
69
  entry.deleteCredential();
69
70
  }
70
71
  catch {
71
- throw new CredentialError(`凭据写入状态未知,清理也失败了。请检查引用 ID:${id}`);
72
+ throw new CredentialError(t('credentials.writeUnknown', { id }));
72
73
  }
73
- throw new CredentialError('系统凭据写入失败,配置没有切换。');
74
+ throw new CredentialError(t('credentials.writeFailed'));
74
75
  }
75
76
  }
76
77
  remove(id) {
@@ -79,27 +80,27 @@ export class KeyringStore {
79
80
  this.entry(id).deleteCredential();
80
81
  }
81
82
  catch {
82
- throw new CredentialError('系统凭据删除失败,请检查存储权限。');
83
+ throw new CredentialError(t('credentials.deleteFailed'));
83
84
  }
84
85
  }
85
86
  }
86
87
  const require = createRequire(import.meta.url);
87
88
  export function nativeStore(platform = process.platform) {
88
89
  if (platform !== 'win32' && platform !== 'darwin')
89
- throw new CredentialError('系统凭据仅支持 Windows 和 macOS;其他平台请使用环境变量。');
90
+ throw new CredentialError(t('credentials.platform'));
90
91
  try {
91
92
  const binding = require('@napi-rs/keyring');
92
93
  return new KeyringStore(binding.Entry);
93
94
  }
94
95
  catch {
95
- throw new CredentialError('原生凭据组件无法加载。请确认 npm 安装保留了 optional 依赖,并且架构匹配;不会降级为明文存储。');
96
+ throw new CredentialError(t('credentials.nativeLoad'));
96
97
  }
97
98
  }
98
99
  export function resolveReference(reference) {
99
100
  if (reference.backend === 'system')
100
- throw new CredentialError('这是旧版原型保存的凭据引用。请用 setup 重新输入并保存为 Node 凭据,或显式提供环境变量;不会自动读取或删除旧凭据。');
101
+ throw new CredentialError(t('credentials.oldReference'));
101
102
  const values = nativeStore().read(reference.id);
102
103
  if (values === null)
103
- throw new CredentialError('系统凭据条目缺失,请运行 setup 重新配置。');
104
+ throw new CredentialError(t('credentials.missingEntry'));
104
105
  return values;
105
106
  }