@quecue/dev-notify 0.1.0-alpha.3 → 0.1.1
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.md +23 -1
- package/README.md +84 -65
- package/SECURITY.md +46 -46
- package/dist/channels.js +9 -9
- package/dist/claude-code.js +35 -36
- package/dist/claude-hook.js +36 -23
- package/dist/cli.js +91 -67
- package/dist/config.js +16 -16
- package/dist/credentials.js +13 -13
- package/dist/formatting.js +4 -4
- package/dist/prompts.js +6 -6
- package/dist/reply-bridge.js +9 -9
- package/dist/reply-engine.js +23 -17
- package/dist/reply-hooks.js +17 -17
- package/dist/reply-interactions.js +26 -19
- package/dist/reply-outcomes.js +13 -13
- package/dist/reply-platform.js +40 -13
- package/dist/reply-state.js +28 -28
- package/dist/reply-telegram.js +1 -1
- package/dist/setup.js +74 -43
- package/dist/update.js +92 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9,32 +9,39 @@ import { deliver, makePlan } from './channels.js';
|
|
|
9
9
|
import { wizard, clearCredentials } from './setup.js';
|
|
10
10
|
import { terminalPrompts, readText, Cancelled } from './prompts.js';
|
|
11
11
|
import { CredentialError } from './credentials.js';
|
|
12
|
-
import { claudeCodeStatus,
|
|
12
|
+
import { claudeCodeStatus, claudeSettingsPath, configureClaudeCode } from './claude-code.js';
|
|
13
13
|
import { dispatchClaudeHook, readHookInput } from './claude-hook.js';
|
|
14
14
|
import { accessReplies, configureReplies, disableReplies } from './reply-state.js';
|
|
15
15
|
import { startBridge, stopBridge, bridgeStatus, runBridge } from './reply-bridge.js';
|
|
16
|
-
|
|
16
|
+
import { updatePackage } from './update.js';
|
|
17
|
+
export const VERSION = '0.1.1';
|
|
18
|
+
const HELP = `dev-notify ${VERSION} — 跨平台开发任务通知
|
|
17
19
|
|
|
18
20
|
命令:
|
|
19
21
|
send --title 标题 [--content 正文 | --content-file 文件 | --stdin]
|
|
20
22
|
[--channel all|telegram|feishu] [--level info|success|warn|error]
|
|
21
23
|
[--footer 辅助信息] [--dry-run]
|
|
22
|
-
setup
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
setup [--no-test] [--no-hooks]
|
|
25
|
+
配置渠道及系统凭据;默认同时发送测试并安装
|
|
26
|
+
Claude Code 通知 hooks,一次确认完成
|
|
27
|
+
setup --template [--yes] 只创建无密钥模板;不会覆盖已有配置,也不会安装 hooks
|
|
28
|
+
credentials clear 移除当前 Node 凭据引用和条目(需要确认)
|
|
29
|
+
paths 显示配置路径(不会写入文件)
|
|
30
|
+
doctor / status 离线检查(不会发送消息)
|
|
31
|
+
update [--check] [--yes] 检查并更新到 npm 最新版本(确认后调用 npm)
|
|
27
32
|
integration list 列出可用集成
|
|
28
33
|
integration claude-code install / status / remove
|
|
29
34
|
单独安装、查询或移除用户级 hooks
|
|
30
|
-
reply setup
|
|
35
|
+
reply setup 授权可信的 Telegram 私聊用户
|
|
31
36
|
reply start / status / stop
|
|
32
37
|
启动、查询或停止回复桥接
|
|
33
|
-
reply disable
|
|
38
|
+
reply disable 撤销远程回复授权并停止桥接(需要确认)
|
|
34
39
|
reply run 前台运行桥接(排错用,不启用系统自启动)
|
|
35
40
|
|
|
36
41
|
全局选项:--config 路径、--help、--version
|
|
37
|
-
|
|
42
|
+
环境变量:TELEGRAM_BOT_TOKEN、TELEGRAM_CHAT_ID / TELEGRAM_CHAT_IDS、
|
|
43
|
+
FEISHU_WEBHOOK、FEISHU_SECRET;显式存在时优先。详见 README。
|
|
44
|
+
系统凭据保存在 Windows Credential Manager / macOS Keychain;不会写入明文密钥。`;
|
|
38
45
|
const defaults = {
|
|
39
46
|
env: process.env,
|
|
40
47
|
out: text => console.log(text),
|
|
@@ -44,7 +51,7 @@ const defaults = {
|
|
|
44
51
|
hookStdin: () => readHookInput(),
|
|
45
52
|
confirm: async () => {
|
|
46
53
|
if (!process.stdin.isTTY)
|
|
47
|
-
throw new ConfigError('
|
|
54
|
+
throw new ConfigError('当前不是交互终端。自动化场景请使用 setup --template --yes。');
|
|
48
55
|
return /^(y|yes)$/i.test(await readText('确认创建模板?[y/N] '));
|
|
49
56
|
},
|
|
50
57
|
send: deliver,
|
|
@@ -60,10 +67,11 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
|
|
|
60
67
|
title: { type: 'string' }, content: { type: 'string' }, 'content-file': { type: 'string' }, stdin: { type: 'boolean' },
|
|
61
68
|
channel: { type: 'string' }, level: { type: 'string' }, footer: { type: 'string' }, 'dry-run': { type: 'boolean' },
|
|
62
69
|
template: { type: 'boolean' }, yes: { type: 'boolean' },
|
|
70
|
+
'no-test': { type: 'boolean' }, 'no-hooks': { type: 'boolean' }, check: { type: 'boolean' },
|
|
63
71
|
} });
|
|
64
72
|
}
|
|
65
73
|
catch {
|
|
66
|
-
throw new ConfigError('
|
|
74
|
+
throw new ConfigError('参数无效,请运行 --help 查看用法。');
|
|
67
75
|
}
|
|
68
76
|
const { values, positionals } = parsed;
|
|
69
77
|
if (values.help) {
|
|
@@ -71,30 +79,36 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
|
|
|
71
79
|
return 0;
|
|
72
80
|
}
|
|
73
81
|
if (values.version) {
|
|
74
|
-
context.out(
|
|
82
|
+
context.out(`dev-notify ${VERSION}`);
|
|
75
83
|
return 0;
|
|
76
84
|
}
|
|
77
85
|
const [command, action, integrationAction] = positionals;
|
|
78
86
|
const allowed = {
|
|
79
87
|
send: ['title', 'content', 'content-file', 'stdin', 'channel', 'level', 'footer', 'dry-run'],
|
|
80
|
-
setup: ['template', 'yes'], paths: [], doctor: [], status: [], integration: [], credentials: [], reply: [],
|
|
88
|
+
setup: ['template', 'yes', 'no-test', 'no-hooks'], paths: [], doctor: [], status: [], integration: [], credentials: [], reply: [], update: ['check', 'yes'],
|
|
81
89
|
};
|
|
82
90
|
if (!command || !Object.hasOwn(allowed, command) || positionals.length > (command === 'integration' ? 3 : command === 'credentials' || command === 'reply' ? 2 : 1)
|
|
83
91
|
|| Object.keys(values).some(key => key !== 'config' && !allowed[command].includes(key)))
|
|
84
|
-
throw new ConfigError('
|
|
92
|
+
throw new ConfigError('命令或选项组合不正确,请运行 --help 查看用法。');
|
|
85
93
|
const paths = appPaths(process.platform, context.env);
|
|
86
94
|
const file = values.config ? path.resolve(values.config) : paths.config;
|
|
87
95
|
if (command === 'paths') {
|
|
88
96
|
context.out(JSON.stringify({ config: file, data: paths.data }, null, 2));
|
|
89
97
|
return 0;
|
|
90
98
|
}
|
|
99
|
+
if (command === 'update') {
|
|
100
|
+
return await updatePackage(VERSION, {
|
|
101
|
+
out: context.out, prompts: context.prompts,
|
|
102
|
+
check: !!values.check, yes: !!values.yes,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
91
105
|
if (command === 'integration') {
|
|
92
106
|
if (action === 'list' && !integrationAction) {
|
|
93
|
-
context.out('claude-code
|
|
107
|
+
context.out('claude-code:支持通知、Telegram 回复和结构化问题交互。通知 hooks 与回复权限分开,默认只发送通知,不接收远程输入。');
|
|
94
108
|
return 0;
|
|
95
109
|
}
|
|
96
110
|
if (action !== 'claude-code' || !['install', 'status', 'remove', 'hook'].includes(integrationAction))
|
|
97
|
-
throw new ConfigError('请使用 integration list
|
|
111
|
+
throw new ConfigError('请使用 integration list,或 integration claude-code install / status / remove。');
|
|
98
112
|
if (integrationAction === 'hook') {
|
|
99
113
|
await dispatchClaudeHook(await context.hookStdin(), file, { ...context, response: value => context.out(JSON.stringify(value)) });
|
|
100
114
|
return 0;
|
|
@@ -103,44 +117,44 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
|
|
|
103
117
|
const status = await claudeCodeStatus({ env: context.env });
|
|
104
118
|
context.out(`Claude Code 用户级设置:${status.file}`);
|
|
105
119
|
context.out(`本工具 hooks:Stop ${status.stop} 个,Notification ${status.notification} 个,会话生命周期 ${status.lifecycle}/3 个,交互及结果确认 ${status.interaction}/5 个;其他处理器 ${status.others} 个。`);
|
|
106
|
-
context.out(status.disabled ? '
|
|
107
|
-
: '
|
|
120
|
+
context.out(status.disabled ? '当前文件启用了 disableAllHooks,安装后 hooks 不会生效;请自行解除禁用。'
|
|
121
|
+
: '这里只检查用户级文件。是否实际加载还受项目和托管策略影响,请在 Claude Code 的 /hooks 中确认。');
|
|
108
122
|
return status.stop === 1 && status.notification === 1 && status.lifecycle === 3 && status.interaction === 5 && !status.disabled ? 0 : 1;
|
|
109
123
|
}
|
|
110
124
|
const prompts = context.prompts ?? terminalPrompts();
|
|
111
125
|
if (integrationAction === 'install') {
|
|
112
126
|
const config = await loadConfig(file, true, context.env);
|
|
113
127
|
if (!ready(config, 'telegram') && !ready(config, 'feishu'))
|
|
114
|
-
throw new ConfigError('请先运行 setup
|
|
128
|
+
throw new ConfigError('请先运行 setup 配置至少一个通知渠道,再安装 Claude Code hooks。');
|
|
115
129
|
}
|
|
116
130
|
return await configureClaudeCode(file, integrationAction, prompts, { env: context.env }) ? 0 : 130;
|
|
117
131
|
}
|
|
118
132
|
if (command === 'reply') {
|
|
119
133
|
if (!['setup', 'start', 'status', 'stop', 'disable', 'run'].includes(action))
|
|
120
|
-
throw new ConfigError('请使用 reply setup / start / status / stop / disable / run');
|
|
134
|
+
throw new ConfigError('请使用 reply setup / start / status / stop / disable / run。');
|
|
121
135
|
if (action === 'setup') {
|
|
122
136
|
const saved = await configureReplies(file, context.prompts ?? terminalPrompts(), context.env);
|
|
123
137
|
if (saved)
|
|
124
|
-
context.out('
|
|
138
|
+
context.out('授权已保存。请确认 hooks 已安装,然后运行 reply start。旧消息的回复映射不会保留。');
|
|
125
139
|
return saved ? 0 : 130;
|
|
126
140
|
}
|
|
127
141
|
if (action === 'disable') {
|
|
128
142
|
if (!await disableReplies(file, context.prompts ?? terminalPrompts()))
|
|
129
143
|
return 130;
|
|
130
144
|
const stopped = await stopBridge(file);
|
|
131
|
-
context.out(stopped ? '桥接已停止或未运行。' : '
|
|
145
|
+
context.out(stopped ? '桥接已停止或未运行。' : '已撤销授权并请求停止。正在进行中的调用无法撤回。');
|
|
132
146
|
return 0;
|
|
133
147
|
}
|
|
134
148
|
if (action === 'stop') {
|
|
135
149
|
const stopped = await stopBridge(file);
|
|
136
|
-
context.out(stopped ? '
|
|
137
|
-
: '
|
|
150
|
+
context.out(stopped ? '桥接已停止或未运行。授权仍然保留,下次事件可以再次启动;如需彻底关闭,请运行 reply disable。'
|
|
151
|
+
: '已发出停止请求,桥接正在退出。可运行 reply status 查看状态。');
|
|
138
152
|
return stopped ? 0 : 1;
|
|
139
153
|
}
|
|
140
154
|
if (action === 'status') {
|
|
141
155
|
const access = await accessReplies(file, context.env), status = await bridgeStatus(file);
|
|
142
|
-
context.out(access ?
|
|
143
|
-
context.out(status.ready ? '桥接:已就绪。' : status.running ? '
|
|
156
|
+
context.out(access ? `远程回复:已授权 ${access.policy.users.length} 个私聊用户;工具权限逐次确认${access.policy.permissions ? '已开启' : '未开启'}。` : '远程回复:尚未启用。');
|
|
157
|
+
context.out(status.ready ? '桥接:已就绪。' : status.running ? '桥接:进程存在,但还没有就绪。' : '桥接:未运行。');
|
|
144
158
|
if (status.error)
|
|
145
159
|
context.err(status.error);
|
|
146
160
|
return access && status.ready ? 0 : 1;
|
|
@@ -150,22 +164,39 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
|
|
|
150
164
|
return 0;
|
|
151
165
|
}
|
|
152
166
|
const ready = await startBridge(file, context.env);
|
|
153
|
-
context.out(ready ? '
|
|
167
|
+
context.out(ready ? '回复桥接已就绪。请从新的任务通知进入。' : '桥接还没有就绪。请运行 reply status 或 reply run 查看原因;如果旧版桥接在运行,请先停用它。');
|
|
154
168
|
return ready ? 0 : 1;
|
|
155
169
|
}
|
|
156
170
|
if (command === 'credentials') {
|
|
157
171
|
if (action !== 'clear')
|
|
158
|
-
throw new ConfigError('
|
|
172
|
+
throw new ConfigError('credentials 目前只支持 clear,并且需要在交互终端中确认。');
|
|
159
173
|
return await clearCredentials(file, context.prompts ?? terminalPrompts()) ? 0 : 130;
|
|
160
174
|
}
|
|
161
175
|
if (command === 'setup') {
|
|
162
176
|
if (!values.template) {
|
|
163
177
|
if (values.yes)
|
|
164
|
-
throw new ConfigError('--yes
|
|
178
|
+
throw new ConfigError('--yes 只能和 --template 一起使用;保存密钥前仍然需要确认。');
|
|
165
179
|
const prompts = context.prompts ?? terminalPrompts();
|
|
180
|
+
const notes = [];
|
|
181
|
+
const test = !values['no-test'];
|
|
182
|
+
if (test)
|
|
183
|
+
notes.push('保存后会向最终生效的接收目标发送一条固定内容的测试通知。');
|
|
184
|
+
let hooks = !values['no-hooks'];
|
|
185
|
+
if (hooks) {
|
|
186
|
+
try {
|
|
187
|
+
notes.push(`Claude Code 用户级设置:${claudeSettingsPath(context.env)}`);
|
|
188
|
+
notes.push('会为该设置文件对应的所有项目安装通知 hooks。最终回复正文可能包含敏感内容,将发送到配置实际生效的渠道。');
|
|
189
|
+
notes.push('同时安装会话生命周期和结构化问题 hooks。远程输入只有在运行 reply setup 授权后才会开启。');
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
hooks = false;
|
|
193
|
+
context.out(error instanceof ConfigError ? `跳过 Claude Code hooks:${error.message}` : '跳过 Claude Code hooks:没有找到用户级设置路径。');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
let integrationFailed = false;
|
|
166
197
|
const saved = await wizard(file, {
|
|
167
|
-
prompts, store: context.store, env: context.env,
|
|
168
|
-
testSend: async (config) => {
|
|
198
|
+
prompts, store: context.store, env: context.env, notes,
|
|
199
|
+
testSend: test ? async (config) => {
|
|
169
200
|
let count = 0, ok = true;
|
|
170
201
|
for (const channel of ['telegram', 'feishu']) {
|
|
171
202
|
if (!ready(config, channel))
|
|
@@ -173,57 +204,50 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
|
|
|
173
204
|
count++;
|
|
174
205
|
const plan = makePlan(channel, { title: 'dev-notify 配置测试', content: '这是一条由你确认发送的测试通知。', footer: '', level: 'info' }, config);
|
|
175
206
|
const result = await context.send(plan, config);
|
|
176
|
-
context.out(`${channel}
|
|
207
|
+
context.out(`${channel}:测试已发送 ${result.sent}/${result.planned} 条`);
|
|
177
208
|
ok = ok && result.ok;
|
|
178
209
|
}
|
|
179
210
|
return count > 0 && ok;
|
|
180
|
-
},
|
|
211
|
+
} : undefined,
|
|
212
|
+
afterSave: hooks ? async () => {
|
|
213
|
+
try {
|
|
214
|
+
await configureClaudeCode(file, 'install', prompts, { env: context.env, confirm: false });
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
integrationFailed = true;
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
} : undefined,
|
|
181
221
|
});
|
|
182
222
|
if (!saved)
|
|
183
223
|
return 130;
|
|
184
|
-
|
|
185
|
-
await offerClaudeCode(file, prompts, { env: context.env });
|
|
186
|
-
const config = await loadConfig(file, true, context.env);
|
|
187
|
-
if (ready(config, 'telegram') && /^(y|yes)$/i.test(await prompts.text('是否另行配置可信私聊用户回复 Claude?默认关闭。[y/N] '))) {
|
|
188
|
-
await configureReplies(file, prompts, context.env);
|
|
189
|
-
context.out('回复授权流程结束;确认 hooks 已安装后可运行 reply start,或由下一次支持的会话事件启动。');
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
if (error instanceof Cancelled) {
|
|
194
|
-
context.out('渠道配置已保存;已取消后续 Claude Code 集成/回复配置。');
|
|
195
|
-
return 0;
|
|
196
|
-
}
|
|
197
|
-
context.err('渠道配置已保存,但 Claude Code hook 或回复配置未完成;请运行 integration claude-code status / reply status 检查后重试。');
|
|
198
|
-
return 1;
|
|
199
|
-
}
|
|
200
|
-
return 0;
|
|
224
|
+
return integrationFailed ? 1 : 0;
|
|
201
225
|
}
|
|
202
|
-
context.out(
|
|
226
|
+
context.out(`将创建无密钥模板:${file}。不会联网,也不会修改 hooks。`);
|
|
203
227
|
if (!values.yes && !await context.confirm()) {
|
|
204
|
-
context.out('
|
|
228
|
+
context.out('已取消,没有写入。');
|
|
205
229
|
return 130;
|
|
206
230
|
}
|
|
207
231
|
await createConfig(file);
|
|
208
|
-
context.out('
|
|
232
|
+
context.out('模板已创建。请配置环境凭据和接收人。');
|
|
209
233
|
return 0;
|
|
210
234
|
}
|
|
211
235
|
const config = await loadConfig(file, values.config !== undefined, context.env);
|
|
212
236
|
if (command === 'doctor' || command === 'status') {
|
|
213
237
|
context.out(`配置路径: ${file}`);
|
|
214
238
|
for (const channel of ['telegram', 'feishu'])
|
|
215
|
-
context.out(`${channel}
|
|
216
|
-
context.out('
|
|
239
|
+
context.out(`${channel}:${ready(config, channel) ? '配置完整(尚未验证网络)' : '未启用或配置不完整'}`);
|
|
240
|
+
context.out('凭据:优先使用环境变量,其次系统凭据;本检查不联网,系统可能弹出授权提示。');
|
|
217
241
|
return ready(config, 'telegram') || ready(config, 'feishu') ? 0 : 1;
|
|
218
242
|
}
|
|
219
243
|
if (!values.title?.trim())
|
|
220
|
-
throw new ConfigError('send
|
|
244
|
+
throw new ConfigError('send 需要提供非空的 --title。');
|
|
221
245
|
if ([values.content !== undefined, values['content-file'] !== undefined, !!values.stdin].filter(Boolean).length > 1)
|
|
222
|
-
throw new ConfigError('
|
|
246
|
+
throw new ConfigError('--content、--content-file 和 --stdin 只能选择一个。');
|
|
223
247
|
const level = values.level ?? 'info';
|
|
224
248
|
const channel = values.channel ?? 'all';
|
|
225
249
|
if (!['info', 'success', 'warn', 'error'].includes(level) || !['all', 'telegram', 'feishu'].includes(channel))
|
|
226
|
-
throw new ConfigError('
|
|
250
|
+
throw new ConfigError('渠道或通知级别不正确。');
|
|
227
251
|
let content = values.content ?? values.title;
|
|
228
252
|
if (values['content-file'] !== undefined)
|
|
229
253
|
content = decodeUtf8(await readFile(values['content-file']));
|
|
@@ -235,7 +259,7 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
|
|
|
235
259
|
for (const name of selected) {
|
|
236
260
|
if (!ready(config, name)) {
|
|
237
261
|
if (channel !== 'all')
|
|
238
|
-
throw new ConfigError('
|
|
262
|
+
throw new ConfigError('这个渠道未启用,或配置不完整。');
|
|
239
263
|
continue;
|
|
240
264
|
}
|
|
241
265
|
planned++;
|
|
@@ -244,31 +268,31 @@ export async function main(argv = process.argv.slice(2), overrides = {}) {
|
|
|
244
268
|
plan = makePlan(name, message, config);
|
|
245
269
|
}
|
|
246
270
|
catch {
|
|
247
|
-
context.err(`${name}
|
|
271
|
+
context.err(`${name}:无法安全分段。请缩短标题或底部信息,或检查正文格式。`);
|
|
248
272
|
failed = true;
|
|
249
273
|
continue;
|
|
250
274
|
}
|
|
251
275
|
if (values['dry-run']) {
|
|
252
|
-
context.out(`${name}
|
|
276
|
+
context.out(`${name}:接收目标 ${plan.recipients},预计请求 ${plan.items.length} 条(dry-run,没有实际发送)`);
|
|
253
277
|
continue;
|
|
254
278
|
}
|
|
255
279
|
const result = await context.send(plan, config);
|
|
256
|
-
context.out(`${name}
|
|
280
|
+
context.out(`${name}:已发送 ${result.sent}/${result.planned} 条`);
|
|
257
281
|
if (!result.ok) {
|
|
258
282
|
failed = true;
|
|
259
|
-
context.err(result.error ?? '
|
|
283
|
+
context.err(result.error ?? '发送失败。');
|
|
260
284
|
}
|
|
261
285
|
}
|
|
262
286
|
if (!planned)
|
|
263
|
-
throw new ConfigError('
|
|
287
|
+
throw new ConfigError('没有可用渠道,请先配置环境凭据和接收人。');
|
|
264
288
|
return failed ? 1 : 0;
|
|
265
289
|
}
|
|
266
290
|
catch (error) {
|
|
267
291
|
if (error instanceof Cancelled) {
|
|
268
|
-
context.out('
|
|
292
|
+
context.out('已取消。');
|
|
269
293
|
return isHook ? 0 : 130;
|
|
270
294
|
}
|
|
271
|
-
context.err(error instanceof ConfigError || error instanceof CredentialError ? error.message : '
|
|
295
|
+
context.err(error instanceof ConfigError || error instanceof CredentialError ? error.message : '操作失败。请检查文件、权限或输入;为避免泄露密钥,这里不显示底层异常。');
|
|
272
296
|
// Stop hook 的退出码 2 会要求 Claude 继续;通知失败只能报告,不能阻塞或触发重试循环。
|
|
273
297
|
return isHook ? 0 : 2;
|
|
274
298
|
}
|
package/dist/config.js
CHANGED
|
@@ -13,7 +13,7 @@ export function appPaths(platform = process.platform, env = process.env, home) {
|
|
|
13
13
|
const join = platform === 'win32' ? path.win32 : path.posix;
|
|
14
14
|
if (env.DEV_NOTIFY_HOME) {
|
|
15
15
|
if (!join.isAbsolute(env.DEV_NOTIFY_HOME))
|
|
16
|
-
throw new ConfigError('DEV_NOTIFY_HOME
|
|
16
|
+
throw new ConfigError('DEV_NOTIFY_HOME 需要是绝对路径。');
|
|
17
17
|
return { config: join.join(env.DEV_NOTIFY_HOME, 'config', 'config.json'), data: join.join(env.DEV_NOTIFY_HOME, 'data') };
|
|
18
18
|
}
|
|
19
19
|
home ??= homedir();
|
|
@@ -31,23 +31,23 @@ export function appPaths(platform = process.platform, env = process.env, home) {
|
|
|
31
31
|
}
|
|
32
32
|
export function object(value, allowed, name) {
|
|
33
33
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
34
|
-
throw new ConfigError(`${name}
|
|
34
|
+
throw new ConfigError(`${name} 需要是 JSON 对象。`);
|
|
35
35
|
const result = value;
|
|
36
36
|
if (Object.keys(result).some(key => !allowed.includes(key)))
|
|
37
|
-
throw new ConfigError(`${name}
|
|
37
|
+
throw new ConfigError(`${name} 包含不支持的字段;不能直接保存明文密钥。`);
|
|
38
38
|
return result;
|
|
39
39
|
}
|
|
40
40
|
function enabled(section) {
|
|
41
41
|
if (section.enabled === undefined)
|
|
42
42
|
return true;
|
|
43
43
|
if (typeof section.enabled !== 'boolean')
|
|
44
|
-
throw new ConfigError('enabled
|
|
44
|
+
throw new ConfigError('enabled 需要是布尔值。');
|
|
45
45
|
return section.enabled;
|
|
46
46
|
}
|
|
47
47
|
function secret(section, field, fallback, env, stored) {
|
|
48
48
|
const name = section[field] ?? fallback;
|
|
49
49
|
if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
50
|
-
throw new ConfigError('
|
|
50
|
+
throw new ConfigError('凭据引用需要是合法的环境变量名称。');
|
|
51
51
|
if (Object.hasOwn(env, name))
|
|
52
52
|
return (env[name] ?? '').trim();
|
|
53
53
|
return enabled(section) ? stored().trim() : '';
|
|
@@ -55,12 +55,12 @@ function secret(section, field, fallback, env, stored) {
|
|
|
55
55
|
export function parseConfig(input, env = process.env, resolver = resolveReference) {
|
|
56
56
|
const data = object(input, ['version', 'telegram', 'feishu', 'credentials'], '配置');
|
|
57
57
|
if ((data.version ?? 1) !== 1)
|
|
58
|
-
throw new ConfigError('
|
|
58
|
+
throw new ConfigError('不支持这个配置版本。');
|
|
59
59
|
let reference;
|
|
60
60
|
if (data.credentials !== undefined) {
|
|
61
61
|
const value = object(data.credentials, ['backend', 'id'], 'credentials');
|
|
62
62
|
if (!['system', 'keyring'].includes(String(value.backend)) || typeof value.id !== 'string' || !/^[a-f0-9]{32}$/.test(value.id))
|
|
63
|
-
throw new ConfigError('
|
|
63
|
+
throw new ConfigError('系统凭据引用无效。');
|
|
64
64
|
reference = value;
|
|
65
65
|
}
|
|
66
66
|
let cached;
|
|
@@ -78,18 +78,18 @@ export function parseConfig(input, env = process.env, resolver = resolveReferenc
|
|
|
78
78
|
ids = `${env.TELEGRAM_CHAT_ID ?? ''},${env.TELEGRAM_CHAT_IDS ?? ''}`.split(',').map(v => v.trim()).filter(Boolean);
|
|
79
79
|
}
|
|
80
80
|
if (!Array.isArray(ids))
|
|
81
|
-
throw new ConfigError('telegram.chat_ids
|
|
81
|
+
throw new ConfigError('telegram.chat_ids 需要是数组。');
|
|
82
82
|
const chatIds = [...new Set(ids.map(value => {
|
|
83
83
|
if (typeof value !== 'string' && !(typeof value === 'number' && Number.isSafeInteger(value)))
|
|
84
|
-
throw new ConfigError('
|
|
84
|
+
throw new ConfigError('接收人需要使用数字 chat_id;大整数请写成字符串。');
|
|
85
85
|
const id = String(value).trim();
|
|
86
86
|
if (!/^-?[1-9][0-9]*$/.test(id))
|
|
87
|
-
throw new ConfigError('
|
|
87
|
+
throw new ConfigError('接收人需要是非零的数字 chat_id。');
|
|
88
88
|
return id;
|
|
89
89
|
}))];
|
|
90
90
|
const token = secret(tg, 'token_env', 'TELEGRAM_BOT_TOKEN', env, () => stored('telegram_token'));
|
|
91
91
|
if (token && !/^[0-9]+:[A-Za-z0-9_-]+$/.test(token))
|
|
92
|
-
throw new ConfigError('Telegram Token
|
|
92
|
+
throw new ConfigError('Telegram Token 格式不正确。');
|
|
93
93
|
const webhook = secret(fs, 'webhook_env', 'FEISHU_WEBHOOK', env, () => stored('feishu_webhook'));
|
|
94
94
|
if (webhook) {
|
|
95
95
|
let valid = false;
|
|
@@ -99,7 +99,7 @@ export function parseConfig(input, env = process.env, resolver = resolveReferenc
|
|
|
99
99
|
}
|
|
100
100
|
catch { /* 不输出含秘密的 URL 异常。 */ }
|
|
101
101
|
if (!valid)
|
|
102
|
-
throw new ConfigError('飞书 webhook
|
|
102
|
+
throw new ConfigError('飞书 webhook 需要是 open.feishu.cn 的 HTTPS 自定义机器人地址。');
|
|
103
103
|
}
|
|
104
104
|
return { telegram: { enabled: tgEnabled, token, chatIds }, feishu: { enabled: fsEnabled, webhook,
|
|
105
105
|
secret: secret(fs, 'secret_env', 'FEISHU_SECRET', env, () => stored('feishu_secret')) } };
|
|
@@ -113,7 +113,7 @@ export function decodeUtf8(data) {
|
|
|
113
113
|
return new TextDecoder('utf-8', { fatal: true }).decode(data);
|
|
114
114
|
}
|
|
115
115
|
catch {
|
|
116
|
-
throw new ConfigError('
|
|
116
|
+
throw new ConfigError('输入需要是有效的 UTF-8 文本。');
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
export async function loadConfig(file, required = false, env = process.env) {
|
|
@@ -124,7 +124,7 @@ export async function loadConfig(file, required = false, env = process.env) {
|
|
|
124
124
|
catch (error) {
|
|
125
125
|
if (error.code === 'ENOENT' && !required)
|
|
126
126
|
return parseConfig({}, env);
|
|
127
|
-
throw new ConfigError('
|
|
127
|
+
throw new ConfigError('配置文件缺失、无法读取,或不是合法的 UTF-8 JSON。');
|
|
128
128
|
}
|
|
129
129
|
return parseConfig(input, env);
|
|
130
130
|
}
|
|
@@ -135,7 +135,7 @@ export async function createConfig(file) {
|
|
|
135
135
|
}
|
|
136
136
|
catch (error) {
|
|
137
137
|
if (error.code === 'EEXIST')
|
|
138
|
-
throw new ConfigError('
|
|
139
|
-
throw new ConfigError('
|
|
138
|
+
throw new ConfigError('配置已经存在,没有覆盖。Node 版不会修改旧版原型的配置。');
|
|
139
|
+
throw new ConfigError('无法创建配置,请检查路径和权限。');
|
|
140
140
|
}
|
|
141
141
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -6,7 +6,7 @@ export class CredentialError extends Error {
|
|
|
6
6
|
const fields = ['telegram_token', 'feishu_webhook', 'feishu_secret'];
|
|
7
7
|
export function validateId(id) {
|
|
8
8
|
if (typeof id !== 'string' || !/^[0-9a-f]{32}$/.test(id))
|
|
9
|
-
throw new CredentialError('
|
|
9
|
+
throw new CredentialError('系统凭据引用无效。');
|
|
10
10
|
}
|
|
11
11
|
export function decodeBundle(raw) {
|
|
12
12
|
try {
|
|
@@ -18,7 +18,7 @@ export function decodeBundle(raw) {
|
|
|
18
18
|
return result;
|
|
19
19
|
}
|
|
20
20
|
catch {
|
|
21
|
-
throw new CredentialError('系统凭据数据无效,请重新运行 setup');
|
|
21
|
+
throw new CredentialError('系统凭据数据无效,请重新运行 setup。');
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
export function encodeBundle(values) {
|
|
@@ -26,7 +26,7 @@ export function encodeBundle(values) {
|
|
|
26
26
|
decodeBundle(encoded);
|
|
27
27
|
// Windows Credential Manager 通用凭据 blob 上限 2560 字节,跨平台采用保守值。
|
|
28
28
|
if (encoded.byteLength > 2400)
|
|
29
|
-
throw new CredentialError('凭据总长度超过 2400
|
|
29
|
+
throw new CredentialError('凭据总长度超过 2400 字节,请检查输入;没有保存。');
|
|
30
30
|
return encoded;
|
|
31
31
|
}
|
|
32
32
|
export class KeyringStore {
|
|
@@ -42,7 +42,7 @@ export class KeyringStore {
|
|
|
42
42
|
return raw === null ? null : decodeBundle(Uint8Array.from(raw));
|
|
43
43
|
}
|
|
44
44
|
catch {
|
|
45
|
-
throw new CredentialError('
|
|
45
|
+
throw new CredentialError('无法读取系统凭据,或内容无效。请解锁系统凭据存储并允许访问,或重新运行 setup 配置。');
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
create(id, values) {
|
|
@@ -52,12 +52,12 @@ export class KeyringStore {
|
|
|
52
52
|
try {
|
|
53
53
|
entry = this.entry(id);
|
|
54
54
|
if (entry.getSecret() !== null)
|
|
55
|
-
throw new CredentialError('
|
|
55
|
+
throw new CredentialError('这个随机凭据引用已经存在,没有覆盖,请重试。');
|
|
56
56
|
}
|
|
57
57
|
catch (error) {
|
|
58
58
|
if (error instanceof CredentialError)
|
|
59
59
|
throw error;
|
|
60
|
-
throw new CredentialError('
|
|
60
|
+
throw new CredentialError('无法访问系统凭据存储,请检查系统权限。');
|
|
61
61
|
}
|
|
62
62
|
try {
|
|
63
63
|
entry.setSecret(encoded);
|
|
@@ -68,9 +68,9 @@ export class KeyringStore {
|
|
|
68
68
|
entry.deleteCredential();
|
|
69
69
|
}
|
|
70
70
|
catch {
|
|
71
|
-
throw new CredentialError(
|
|
71
|
+
throw new CredentialError(`凭据写入状态未知,清理也失败了。请检查引用 ID:${id}`);
|
|
72
72
|
}
|
|
73
|
-
throw new CredentialError('
|
|
73
|
+
throw new CredentialError('系统凭据写入失败,配置没有切换。');
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
remove(id) {
|
|
@@ -79,27 +79,27 @@ export class KeyringStore {
|
|
|
79
79
|
this.entry(id).deleteCredential();
|
|
80
80
|
}
|
|
81
81
|
catch {
|
|
82
|
-
throw new CredentialError('
|
|
82
|
+
throw new CredentialError('系统凭据删除失败,请检查存储权限。');
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
const require = createRequire(import.meta.url);
|
|
87
87
|
export function nativeStore(platform = process.platform) {
|
|
88
88
|
if (platform !== 'win32' && platform !== 'darwin')
|
|
89
|
-
throw new CredentialError('系统凭据仅支持 Windows
|
|
89
|
+
throw new CredentialError('系统凭据仅支持 Windows 和 macOS;其他平台请使用环境变量。');
|
|
90
90
|
try {
|
|
91
91
|
const binding = require('@napi-rs/keyring');
|
|
92
92
|
return new KeyringStore(binding.Entry);
|
|
93
93
|
}
|
|
94
94
|
catch {
|
|
95
|
-
throw new CredentialError('
|
|
95
|
+
throw new CredentialError('原生凭据组件无法加载。请确认 npm 安装保留了 optional 依赖,并且架构匹配;不会降级为明文存储。');
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
export function resolveReference(reference) {
|
|
99
99
|
if (reference.backend === 'system')
|
|
100
|
-
throw new CredentialError('
|
|
100
|
+
throw new CredentialError('这是旧版原型保存的凭据引用。请用 setup 重新输入并保存为 Node 凭据,或显式提供环境变量;不会自动读取或删除旧凭据。');
|
|
101
101
|
const values = nativeStore().read(reference.id);
|
|
102
102
|
if (values === null)
|
|
103
|
-
throw new CredentialError('系统凭据条目缺失,请运行 setup
|
|
103
|
+
throw new CredentialError('系统凭据条目缺失,请运行 setup 重新配置。');
|
|
104
104
|
return values;
|
|
105
105
|
}
|
package/dist/formatting.js
CHANGED
|
@@ -104,7 +104,7 @@ export function visibleText(blocks) {
|
|
|
104
104
|
}
|
|
105
105
|
function cut(text, budget, measure) {
|
|
106
106
|
if (budget < 1)
|
|
107
|
-
throw new Error('
|
|
107
|
+
throw new Error('可用空间不足,无法分段。');
|
|
108
108
|
let used = 0, end = 0;
|
|
109
109
|
for (const ch of text) {
|
|
110
110
|
const size = measure(ch);
|
|
@@ -114,7 +114,7 @@ function cut(text, budget, measure) {
|
|
|
114
114
|
end += ch.length;
|
|
115
115
|
}
|
|
116
116
|
if (!end && text)
|
|
117
|
-
throw new Error('
|
|
117
|
+
throw new Error('可用空间连一个字符都放不下。');
|
|
118
118
|
if (end < text.length) {
|
|
119
119
|
for (const boundary of ['\n\n', '\n', ' ']) {
|
|
120
120
|
const offset = text.slice(0, end).lastIndexOf(boundary);
|
|
@@ -128,7 +128,7 @@ function cut(text, budget, measure) {
|
|
|
128
128
|
}
|
|
129
129
|
export function telegramParts(blocks, budget) {
|
|
130
130
|
if (budget < 2)
|
|
131
|
-
throw new Error('Telegram
|
|
131
|
+
throw new Error('Telegram 标题或底部信息太长,没有剩余空间放正文。');
|
|
132
132
|
const output = [];
|
|
133
133
|
let html = '', plain = '';
|
|
134
134
|
const flush = () => { if (plain)
|
|
@@ -156,7 +156,7 @@ export function telegramParts(blocks, budget) {
|
|
|
156
156
|
}
|
|
157
157
|
export function feishuParts(blocks, budget) {
|
|
158
158
|
if (budget < 128)
|
|
159
|
-
throw new Error('
|
|
159
|
+
throw new Error('飞书标题或底部信息太长,没有剩余空间放正文。');
|
|
160
160
|
const output = [];
|
|
161
161
|
let current = '';
|
|
162
162
|
const size = (text) => Buffer.byteLength(text, 'utf8');
|
package/dist/prompts.js
CHANGED
|
@@ -5,7 +5,7 @@ export class Cancelled extends Error {
|
|
|
5
5
|
}
|
|
6
6
|
export function terminalPrompts() {
|
|
7
7
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
8
|
-
throw new ConfigError('
|
|
8
|
+
throw new ConfigError('配置向导需要在交互终端中运行。自动化场景可以改用 setup --template --yes 搭配环境变量。');
|
|
9
9
|
return {
|
|
10
10
|
out: text => console.log(text),
|
|
11
11
|
text: label => readText(label),
|
|
@@ -22,7 +22,7 @@ export async function readText(label, input = process.stdin, output = process.st
|
|
|
22
22
|
return (await terminal.question(label, { signal: controller.signal })).trim();
|
|
23
23
|
}
|
|
24
24
|
catch {
|
|
25
|
-
throw new Cancelled('
|
|
25
|
+
throw new Cancelled('已取消。');
|
|
26
26
|
}
|
|
27
27
|
finally {
|
|
28
28
|
terminal.off('SIGINT', cancel);
|
|
@@ -32,7 +32,7 @@ export async function readText(label, input = process.stdin, output = process.st
|
|
|
32
32
|
}
|
|
33
33
|
export async function readHidden(label, input = process.stdin, output = process.stdout) {
|
|
34
34
|
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function')
|
|
35
|
-
throw new ConfigError('
|
|
35
|
+
throw new ConfigError('当前终端不支持隐藏输入。为了不暴露密钥,这里不会改用明文输入;请在支持的终端中运行,或用环境变量配置。');
|
|
36
36
|
const wasRaw = input.isRaw, wasPaused = input.isPaused();
|
|
37
37
|
return new Promise((resolve, reject) => {
|
|
38
38
|
let value = '';
|
|
@@ -58,8 +58,8 @@ export async function readHidden(label, input = process.stdin, output = process.
|
|
|
58
58
|
value = '';
|
|
59
59
|
error ? reject(error) : resolve(result);
|
|
60
60
|
};
|
|
61
|
-
const failed = () => finish(new ConfigError('
|
|
62
|
-
const cancelled = () => finish(new Cancelled('
|
|
61
|
+
const failed = () => finish(new ConfigError('隐藏输入没有完成,凭据没有保存。'));
|
|
62
|
+
const cancelled = () => finish(new Cancelled('已取消。'));
|
|
63
63
|
const keypress = (text, key = {}) => {
|
|
64
64
|
if (key.ctrl && (key.name === 'c' || key.name === 'd'))
|
|
65
65
|
return cancelled();
|
|
@@ -76,7 +76,7 @@ export async function readHidden(label, input = process.stdin, output = process.
|
|
|
76
76
|
if (key.ctrl || key.meta || !text || /[\x00-\x1f\x7f]/.test(text))
|
|
77
77
|
return;
|
|
78
78
|
if (value.length + text.length > 8192)
|
|
79
|
-
return finish(new ConfigError('
|
|
79
|
+
return finish(new ConfigError('凭据输入过长,没有保存。'));
|
|
80
80
|
value += text;
|
|
81
81
|
};
|
|
82
82
|
try {
|