@quecue/dev-notify 0.1.3 → 0.1.4
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/CHANGELOG.en.md +83 -0
- package/CHANGELOG.md +9 -0
- package/README.en.md +275 -0
- package/README.md +9 -2
- package/SECURITY.en.md +78 -0
- package/SECURITY.md +4 -2
- package/dist/channels.js +12 -11
- package/dist/claude-code.js +30 -30
- package/dist/claude-hook.js +23 -23
- package/dist/cli.js +106 -81
- package/dist/config.js +37 -20
- package/dist/credentials.js +14 -13
- package/dist/formatting.js +6 -4
- package/dist/i18n.js +960 -0
- package/dist/maintenance.js +17 -16
- package/dist/prompts.js +7 -6
- package/dist/reply-bridge.js +11 -10
- package/dist/reply-engine.js +20 -19
- package/dist/reply-hooks.js +21 -20
- package/dist/reply-interactions.js +36 -35
- package/dist/reply-outcomes.js +46 -23
- package/dist/reply-platform.js +14 -13
- package/dist/reply-state.js +31 -30
- package/dist/reply-telegram.js +4 -3
- package/dist/setup.js +69 -51
- package/dist/update.js +23 -22
- package/package.json +4 -1
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
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.4';
|
|
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('
|
|
57
|
-
return /^(y|yes)$/i.test(await readText('
|
|
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('
|
|
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.
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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('
|
|
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('
|
|
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(
|
|
123
|
-
context.out(
|
|
124
|
-
|
|
125
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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 ? '
|
|
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 ?
|
|
163
|
-
|
|
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 ? '
|
|
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('
|
|
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('
|
|
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(
|
|
194
|
-
notes.push('
|
|
195
|
-
notes.push('
|
|
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 ?
|
|
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: '
|
|
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(
|
|
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(
|
|
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(
|
|
268
|
+
context.out(t('cli.configPath', { file }));
|
|
244
269
|
for (const channel of ['telegram', 'feishu'])
|
|
245
|
-
context.out(
|
|
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('
|
|
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('
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
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(
|
|
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(
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
139
|
-
throw new ConfigError('
|
|
155
|
+
throw new ConfigError(t('config.exists'));
|
|
156
|
+
throw new ConfigError(t('config.createFailed'));
|
|
140
157
|
}
|
|
141
158
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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('
|
|
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(
|
|
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('
|
|
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('
|
|
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('
|
|
101
|
+
throw new CredentialError(t('credentials.oldReference'));
|
|
101
102
|
const values = nativeStore().read(reference.id);
|
|
102
103
|
if (values === null)
|
|
103
|
-
throw new CredentialError('
|
|
104
|
+
throw new CredentialError(t('credentials.missingEntry'));
|
|
104
105
|
return values;
|
|
105
106
|
}
|