@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/reply-platform.js
CHANGED
|
@@ -7,8 +7,8 @@ import { claudeSettingsPath } from './claude-code.js';
|
|
|
7
7
|
export const runCommand = (command, args, input = '') => new Promise((resolve, reject) => {
|
|
8
8
|
const child = spawn(command, args, { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
9
9
|
let stdout = '', overflow = false;
|
|
10
|
-
const timer = setTimeout(() => { child.kill(); reject(new ConfigError('
|
|
11
|
-
const fail = () => { clearTimeout(timer); reject(new ConfigError('
|
|
10
|
+
const timer = setTimeout(() => { child.kill(); reject(new ConfigError('系统会话操作超时。输入可能已经部分送达,为避免重复提交,不会自动重试。')); }, 15000);
|
|
11
|
+
const fail = () => { clearTimeout(timer); reject(new ConfigError('系统会话操作失败。为了避免泄露命令或输入,这里不显示底层信息。')); };
|
|
12
12
|
child.on('error', fail);
|
|
13
13
|
child.stdin.on('error', () => { });
|
|
14
14
|
child.stdout.on('data', data => { if (stdout.length + data.length > 1024 * 1024) {
|
|
@@ -28,12 +28,12 @@ function powershell(script, input, runner) {
|
|
|
28
28
|
const READ_PROCESS = `$ErrorActionPreference='Stop'; [Console]::InputEncoding=[Text.UTF8Encoding]::new($false); $x=[Console]::In.ReadToEnd()|ConvertFrom-Json; $p=Get-Process -Id $x.pid; if($p.ProcessName -ne 'claude'){exit 3}; @{birth=$p.StartTime.ToUniversalTime().Ticks.ToString()}|ConvertTo-Json -Compress`;
|
|
29
29
|
export function sanitizeReply(text) {
|
|
30
30
|
if (typeof text !== 'string' || !text.trim())
|
|
31
|
-
throw new ConfigError('
|
|
31
|
+
throw new ConfigError('回复内容不能为空。');
|
|
32
32
|
if (Array.from(text).length > 4000)
|
|
33
|
-
throw new ConfigError('回复超过 4000
|
|
33
|
+
throw new ConfigError('回复超过 4000 字符,没有提交。内容不会被截断,也不会拆成多次发送。');
|
|
34
34
|
const value = text.replace(/\r\n|[\r\n\u2028\u2029]/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').trim();
|
|
35
35
|
if (!value)
|
|
36
|
-
throw new ConfigError('
|
|
36
|
+
throw new ConfigError('回复清理后没有可提交的内容。');
|
|
37
37
|
return value;
|
|
38
38
|
}
|
|
39
39
|
export async function processIdentity(pid, platform = process.platform, runner = runCommand) {
|
|
@@ -95,6 +95,33 @@ export async function findSession(session, options = {}) {
|
|
|
95
95
|
return { session, pid: d.pid, ...identity, title: typeof d.name === 'string' ? Array.from(d.name).slice(0, 60).join('') : '',
|
|
96
96
|
cwd: typeof d.cwd === 'string' ? d.cwd : '', updated: Date.now(), working: false };
|
|
97
97
|
}
|
|
98
|
+
// 仅用于通知展示:按 sessionId 读取本地会话注册表名称,不做进程身份核验,失败时返回空。
|
|
99
|
+
export async function findSessionName(session, options = {}) {
|
|
100
|
+
if (!session || !/^[A-Za-z0-9_-]{1,128}$/.test(session))
|
|
101
|
+
return '';
|
|
102
|
+
const directory = path.join(path.dirname(claudeSettingsPath(options.env, options.home ?? homedir())), 'sessions');
|
|
103
|
+
let names;
|
|
104
|
+
try {
|
|
105
|
+
names = await readdir(directory);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
if (!/^\d+\.json$/.test(name))
|
|
112
|
+
continue;
|
|
113
|
+
try {
|
|
114
|
+
const bytes = await readFile(path.join(directory, name));
|
|
115
|
+
if (bytes.length > 128 * 1024)
|
|
116
|
+
continue;
|
|
117
|
+
const d = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
118
|
+
if (d.sessionId === session && typeof d.name === 'string' && d.name.trim())
|
|
119
|
+
return d.name;
|
|
120
|
+
}
|
|
121
|
+
catch { /* 其他损坏或写入中的会话文件不提供名称。 */ }
|
|
122
|
+
}
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
98
125
|
export async function validateTarget(target, options = {}) {
|
|
99
126
|
const current = await findSession(target.session, options);
|
|
100
127
|
return current !== null && current.pid === target.pid && current.birth === target.birth && current.tty === target.tty;
|
|
@@ -139,15 +166,15 @@ if([DevNotifyConsole]::Send([uint32]$x.pid,[string]$x.text)){exit 0}else{exit 4}
|
|
|
139
166
|
export async function injectReply(target, text, options = {}) {
|
|
140
167
|
const safe = sanitizeReply(text), runner = options.runner ?? runCommand, platform = options.platform ?? process.platform;
|
|
141
168
|
if (!await validateTarget(target, options))
|
|
142
|
-
throw new ConfigError('
|
|
169
|
+
throw new ConfigError('会话已经关闭、切换,或无法核验身份。请从最新通知回复。');
|
|
143
170
|
if (platform === 'win32') {
|
|
144
171
|
const result = await powershell(WINDOWS_INJECT, { pid: target.pid, birth: target.birth, text: safe }, runner);
|
|
145
172
|
if (result.code !== 0)
|
|
146
|
-
throw new ConfigError('Windows
|
|
173
|
+
throw new ConfigError('Windows 控制台注入失败,可能只写入了一部分。不会自动重试,请检查终端。');
|
|
147
174
|
return;
|
|
148
175
|
}
|
|
149
176
|
if (platform !== 'darwin')
|
|
150
|
-
throw new ConfigError('远程回复仅支持 Windows
|
|
177
|
+
throw new ConfigError('远程回复仅支持 Windows 和 macOS。');
|
|
151
178
|
// 先确定 tmux pane,字面文本通过 stdin 缓冲区传入,绝不作为 shell 或按键名称执行。
|
|
152
179
|
let listing = null;
|
|
153
180
|
try {
|
|
@@ -159,14 +186,14 @@ export async function injectReply(target, text, options = {}) {
|
|
|
159
186
|
const name = 'dev-notify-' + target.pid + '-' + Date.now();
|
|
160
187
|
const loaded = await runner('tmux', ['load-buffer', '-b', name, '-'], safe);
|
|
161
188
|
if (loaded.code !== 0)
|
|
162
|
-
throw new ConfigError('tmux
|
|
189
|
+
throw new ConfigError('tmux 输入缓冲区创建失败,这次回复没有提交。');
|
|
163
190
|
try {
|
|
164
191
|
if (!await validateTarget(target, options))
|
|
165
|
-
throw new ConfigError('
|
|
192
|
+
throw new ConfigError('会话已经变化,这次回复没有提交。');
|
|
166
193
|
if ((await runner('tmux', ['paste-buffer', '-d', '-b', name, '-t', pane])).code !== 0)
|
|
167
|
-
throw new ConfigError('tmux
|
|
194
|
+
throw new ConfigError('tmux 粘贴失败,可能只送达了一部分。不会自动重试。');
|
|
168
195
|
if ((await runner('tmux', ['send-keys', '-t', pane, 'Enter'])).code !== 0)
|
|
169
|
-
throw new ConfigError('tmux
|
|
196
|
+
throw new ConfigError('tmux 提交失败,文本可能已经输入。不会自动重试。');
|
|
170
197
|
}
|
|
171
198
|
finally {
|
|
172
199
|
await runner('tmux', ['delete-buffer', '-b', name]).catch(() => { });
|
|
@@ -178,5 +205,5 @@ export async function injectReply(target, text, options = {}) {
|
|
|
178
205
|
const script = `tell application "Ghostty"\nrepeat with w in windows\nrepeat with t in terminals of w\nif tty of t is "${escape(target.tty)}" then\nperform action "${escape(action)}" on t\nreturn "ok"\nend if\nend repeat\nend repeat\nreturn "notfound"\nend tell`;
|
|
179
206
|
const result = await runner('/usr/bin/osascript', ['-'], script);
|
|
180
207
|
if (result.code !== 0 || result.stdout.trim() !== 'ok')
|
|
181
|
-
throw new ConfigError('Ghostty
|
|
208
|
+
throw new ConfigError('Ghostty 没有找到目标窗口,或注入失败。不会改投其他窗口,也不会重复输入。');
|
|
182
209
|
}
|
package/dist/reply-state.js
CHANGED
|
@@ -20,7 +20,7 @@ export async function readJson(file) {
|
|
|
20
20
|
catch (error) {
|
|
21
21
|
if (error.code === 'ENOENT')
|
|
22
22
|
return null;
|
|
23
|
-
throw new ConfigError('
|
|
23
|
+
throw new ConfigError('无法读取回复配置或状态。为了不覆盖现有数据,远程输入没有启用。');
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
export async function atomicJson(file, value) {
|
|
@@ -52,9 +52,9 @@ export async function locked(file, work) {
|
|
|
52
52
|
}
|
|
53
53
|
catch (error) {
|
|
54
54
|
if (error.code !== 'EEXIST')
|
|
55
|
-
throw new ConfigError('
|
|
55
|
+
throw new ConfigError('无法获取回复状态写入锁。');
|
|
56
56
|
if (attempt === 99)
|
|
57
|
-
throw new ConfigError('
|
|
57
|
+
throw new ConfigError('回复状态锁正忙。请确认相关进程已经退出,再处理残留锁。');
|
|
58
58
|
await delay(40);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
@@ -73,16 +73,16 @@ export async function accessReplies(file, env = process.env) {
|
|
|
73
73
|
if (policy.version !== 1 || policy.enabled !== true || typeof policy.bot !== 'string' || !/^[a-f0-9]{64}$/.test(policy.bot)
|
|
74
74
|
|| typeof policy.generation !== 'string' || !/^[a-f0-9]{32}$/.test(policy.generation)
|
|
75
75
|
|| !Array.isArray(policy.users) || !policy.users.length || policy.users.some((id) => typeof id !== 'string' || !/^[1-9][0-9]*$/.test(id))) {
|
|
76
|
-
throw new ConfigError('
|
|
76
|
+
throw new ConfigError('回复授权配置无效,远程输入已停用。');
|
|
77
77
|
}
|
|
78
78
|
if (!await replyHooksReady(file, { env }))
|
|
79
|
-
throw new ConfigError('
|
|
79
|
+
throw new ConfigError('回复所需的 hooks 缺失、时序不安全或已禁用。请重新运行 integration claude-code install,并在 /hooks 中确认加载。');
|
|
80
80
|
const config = await loadConfig(file, true, env);
|
|
81
81
|
if (!ready(config, 'telegram') || botKey(config) !== policy.bot)
|
|
82
|
-
throw new ConfigError('Telegram Bot 已变更或不可用,请重新运行 reply setup
|
|
82
|
+
throw new ConfigError('Telegram Bot 已变更或不可用,请重新运行 reply setup 授权。');
|
|
83
83
|
const users = policy.users.filter((id) => config.telegram.chatIds.includes(id));
|
|
84
84
|
if (!users.length)
|
|
85
|
-
throw new ConfigError('
|
|
85
|
+
throw new ConfigError('授权用户不在当前 Telegram 接收名单中,远程输入已停用。');
|
|
86
86
|
const effective = { ...policy, users };
|
|
87
87
|
return { file, config, policy: effective, identity: digest(JSON.stringify([policy.bot, policy.generation, users, policy.permissions === true])) };
|
|
88
88
|
}
|
|
@@ -92,7 +92,7 @@ export async function stateTransaction(access, work) {
|
|
|
92
92
|
const policy = await readJson(policyPath(access.file));
|
|
93
93
|
if (!policy?.enabled || policy.bot !== access.policy.bot || policy.generation !== access.policy.generation
|
|
94
94
|
|| (policy.permissions === true) !== (access.policy.permissions === true))
|
|
95
|
-
throw new ConfigError('
|
|
95
|
+
throw new ConfigError('回复授权已撤销或发生变化,没有写入旧状态。');
|
|
96
96
|
let state = await readJson(path.join(directory, 'state.json'));
|
|
97
97
|
if (!state || state.identity !== access.identity)
|
|
98
98
|
state = { version: 1, identity: access.identity, offset: 0, sessions: {}, entries: {}, pending: {}, requests: {}, calls: {} };
|
|
@@ -101,7 +101,7 @@ export async function stateTransaction(access, work) {
|
|
|
101
101
|
state.calls ??= {};
|
|
102
102
|
if (state.version !== 1 || !Number.isSafeInteger(state.offset) || state.offset < 0
|
|
103
103
|
|| ![state.sessions, state.entries, state.pending, state.requests, state.calls].every(record))
|
|
104
|
-
throw new ConfigError('
|
|
104
|
+
throw new ConfigError('回复状态结构无效,远程输入已停用。');
|
|
105
105
|
const now = Date.now();
|
|
106
106
|
for (const section of [state.sessions, state.entries, state.pending, state.requests, state.calls])
|
|
107
107
|
for (const [key, value] of Object.entries(section)) {
|
|
@@ -136,54 +136,54 @@ export async function stateTransaction(access, work) {
|
|
|
136
136
|
}
|
|
137
137
|
export async function configureReplies(file, prompts, env = process.env) {
|
|
138
138
|
if (!await replyHooksReady(file, { env }))
|
|
139
|
-
throw new ConfigError('请先运行 integration claude-code install
|
|
139
|
+
throw new ConfigError('请先运行 integration claude-code install,安装绑定此通知配置的完整 hooks,再配置回复。');
|
|
140
140
|
const config = await loadConfig(file, true, env);
|
|
141
141
|
if (!ready(config, 'telegram'))
|
|
142
|
-
throw new ConfigError('回复 Claude 需要先配置 Telegram Bot
|
|
142
|
+
throw new ConfigError('回复 Claude 需要先配置 Telegram Bot 和私聊接收人。');
|
|
143
143
|
const available = config.telegram.chatIds.filter(id => /^[1-9][0-9]*$/.test(id));
|
|
144
144
|
if (!available.length)
|
|
145
|
-
throw new ConfigError('
|
|
145
|
+
throw new ConfigError('远程回复仅支持 Telegram 私聊用户,不支持群聊。');
|
|
146
146
|
const before = await readJson(policyPath(file));
|
|
147
|
-
prompts.out('
|
|
148
|
-
prompts.out('
|
|
149
|
-
prompts.out('Windows 使用系统 PowerShell / 控制台 API;macOS 使用 Ghostty 或 tmux
|
|
150
|
-
prompts.out('
|
|
151
|
-
prompts.out('
|
|
152
|
-
const text = await prompts.text('
|
|
147
|
+
prompts.out('远程回复等同于在本机 Claude Code 终端输入并提交内容。它可以提交 Claude 命令,但不会自动批准工具权限。请只授权给可信用户。');
|
|
148
|
+
prompts.out('飞书按钮会跳转到 Telegram,不直接接收飞书消息。这个功能需要独占 Bot 的 getUpdates,请不要与旧版桥接或其他机器共用同一个 Bot。');
|
|
149
|
+
prompts.out('Windows 使用系统 PowerShell / 控制台 API;macOS 使用 Ghostty 或 tmux,需要相应的系统权限。');
|
|
150
|
+
prompts.out('目前不支持 VS Code 集成终端、WSL 和 SSH。目标不确定或会话已经切换时,会拒绝输入。');
|
|
151
|
+
prompts.out('通知接收人不会自动获得输入权限。可选择的私聊 chat_id:' + available.join('、'));
|
|
152
|
+
const text = await prompts.text('请输入要授权的私聊 chat_id(逗号分隔;留空取消):');
|
|
153
153
|
if (!text.trim())
|
|
154
154
|
return false;
|
|
155
155
|
const users = [...new Set(text.split(',').map(id => id.trim()))];
|
|
156
156
|
if (users.some(id => !available.includes(id)))
|
|
157
|
-
throw new ConfigError('
|
|
157
|
+
throw new ConfigError('只能授权当前通知名单中的私聊用户。');
|
|
158
158
|
prompts.out('将授权以下用户远程输入:' + users.join('、'));
|
|
159
|
-
prompts.out('
|
|
160
|
-
const permissions = /^(y|yes)$/i.test(await prompts.text('
|
|
159
|
+
prompts.out('结构化问题会展示真实选项,也支持自定义回答;普通回复会在本轮结束后输入。问题、选项和工具请求参数可能包含敏感信息,会发送到授权私聊和已配置的飞书。');
|
|
160
|
+
const permissions = /^(y|yes)$/i.test(await prompts.text('是否允许这些用户逐次批准或拒绝工具权限?这不会修改永久规则。[y/N] '));
|
|
161
161
|
if (!/^(y|yes)$/i.test(await prompts.text('确认启用远程回复并接受上述权限范围?[y/N] ')))
|
|
162
162
|
return false;
|
|
163
163
|
await locked(policyPath(file) + '.lock', async () => {
|
|
164
164
|
if (JSON.stringify(before) !== JSON.stringify(await readJson(policyPath(file))))
|
|
165
|
-
throw new ConfigError('
|
|
165
|
+
throw new ConfigError('回复授权已被其他进程修改,请重试。');
|
|
166
166
|
const effective = await loadConfig(file, true, env);
|
|
167
167
|
if (botKey(effective) !== botKey(config) || users.some(id => !effective.telegram.chatIds.includes(id)))
|
|
168
|
-
throw new ConfigError('
|
|
168
|
+
throw new ConfigError('通知配置在授权期间发生了变化,请重试。');
|
|
169
169
|
await atomicJson(policyPath(file), { version: 1, enabled: true, bot: botKey(config), users, permissions, generation: randomBytes(16).toString('hex') });
|
|
170
170
|
});
|
|
171
|
-
prompts.out('
|
|
171
|
+
prompts.out('授权已保存。旧消息的回复映射不会保留;请从启用后的任务完成通知进入。');
|
|
172
172
|
return true;
|
|
173
173
|
}
|
|
174
174
|
export async function disableReplies(file, prompts) {
|
|
175
175
|
const before = await readJson(policyPath(file));
|
|
176
176
|
if (!before?.enabled) {
|
|
177
|
-
prompts.out('
|
|
177
|
+
prompts.out('远程回复尚未启用。');
|
|
178
178
|
return true;
|
|
179
179
|
}
|
|
180
|
-
if (!/^(y|yes)$/i.test(await prompts.text('
|
|
180
|
+
if (!/^(y|yes)$/i.test(await prompts.text('确认禁用远程回复?通知和凭据都会保留。[y/N] ')))
|
|
181
181
|
return false;
|
|
182
182
|
await locked(policyPath(file) + '.lock', async () => {
|
|
183
183
|
if (JSON.stringify(before) !== JSON.stringify(await readJson(policyPath(file))))
|
|
184
|
-
throw new ConfigError('
|
|
184
|
+
throw new ConfigError('回复授权发生了变化,请重试。');
|
|
185
185
|
await atomicJson(policyPath(file), { ...before, enabled: false });
|
|
186
186
|
});
|
|
187
|
-
prompts.out('
|
|
187
|
+
prompts.out('远程回复已禁用。正在进行的操作无法撤回,桥接会在下次检查时退出。');
|
|
188
188
|
return true;
|
|
189
189
|
}
|
package/dist/reply-telegram.js
CHANGED
|
@@ -7,7 +7,7 @@ export function telegramApi(token, request = requestJson) {
|
|
|
7
7
|
const response = await request(`https://api.telegram.org/bot${token}/${method}`, payload);
|
|
8
8
|
if (response.ok !== true) {
|
|
9
9
|
if (response.error_code === 409)
|
|
10
|
-
throw new ConfigError('Telegram
|
|
10
|
+
throw new ConfigError('Telegram 轮询冲突;检查旧版桥接、其他机器或 webhook,桥接不会抢占或删除 webhook');
|
|
11
11
|
throw new ConfigError('Telegram 桥接请求失败;不输出服务端正文或秘密');
|
|
12
12
|
}
|
|
13
13
|
return response.result;
|
package/dist/setup.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { readFile, mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
|
-
import { ConfigError, decodeUtf8, example, parseConfig } from './config.js';
|
|
4
|
+
import { ConfigError, decodeUtf8, example, parseConfig, ready } from './config.js';
|
|
5
5
|
import { encodeBundle, nativeStore } from './credentials.js';
|
|
6
6
|
import { terminalPrompts } from './prompts.js';
|
|
7
7
|
const yes = (text) => /^(y|yes)$/i.test(text.trim());
|
|
8
|
+
const defaultYes = (text) => !text.trim() || yes(text);
|
|
8
9
|
export async function snapshot(file) {
|
|
9
10
|
try {
|
|
10
11
|
return await readFile(file);
|
|
@@ -12,7 +13,7 @@ export async function snapshot(file) {
|
|
|
12
13
|
catch (error) {
|
|
13
14
|
if (error.code === 'ENOENT')
|
|
14
15
|
return null;
|
|
15
|
-
throw new ConfigError('
|
|
16
|
+
throw new ConfigError('无法读取现有配置,这次没有做任何修改。');
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
function same(left, right) { return left === null ? right === null : right !== null && left.equals(right); }
|
|
@@ -24,7 +25,7 @@ export function readRaw(raw) {
|
|
|
24
25
|
data = JSON.parse(decodeUtf8(raw));
|
|
25
26
|
}
|
|
26
27
|
catch {
|
|
27
|
-
throw new ConfigError('
|
|
28
|
+
throw new ConfigError('现有配置无法解析,这次没有覆盖它。请先修复 JSON。');
|
|
28
29
|
}
|
|
29
30
|
parseConfig(data, {}, () => ({}));
|
|
30
31
|
return data;
|
|
@@ -59,20 +60,20 @@ export async function commitConfig(file, data, expected, store, values, options
|
|
|
59
60
|
lock = await open(lockPath, 'wx', 0o600);
|
|
60
61
|
}
|
|
61
62
|
catch {
|
|
62
|
-
throw new ConfigError('
|
|
63
|
+
throw new ConfigError('配置写入锁不可用。请检查权限;如果确认没有向导在运行,可以删除残留的 .setup.lock。');
|
|
63
64
|
}
|
|
64
65
|
const temporary = path.join(path.dirname(file), `.config-${id}.tmp`);
|
|
65
66
|
let created = false, committed = false, tempCreated = false;
|
|
66
67
|
try {
|
|
67
68
|
await lock.close();
|
|
68
69
|
if (!same(await snapshot(file), expected))
|
|
69
|
-
throw new ConfigError('
|
|
70
|
+
throw new ConfigError('配置在向导期间发生了变化,这次没有覆盖它。请重试。');
|
|
70
71
|
if (values !== null) {
|
|
71
72
|
store.create(id, values);
|
|
72
73
|
created = true;
|
|
73
74
|
const check = store.read(id);
|
|
74
75
|
if (check === null || JSON.stringify(check) !== JSON.stringify(values))
|
|
75
|
-
throw new ConfigError('
|
|
76
|
+
throw new ConfigError('凭据写入后校验失败,配置没有切换。');
|
|
76
77
|
}
|
|
77
78
|
const stream = await open(temporary, 'wx', 0o600);
|
|
78
79
|
tempCreated = true;
|
|
@@ -84,7 +85,7 @@ export async function commitConfig(file, data, expected, store, values, options
|
|
|
84
85
|
await stream.close();
|
|
85
86
|
}
|
|
86
87
|
if (!same(await snapshot(file), expected))
|
|
87
|
-
throw new ConfigError('
|
|
88
|
+
throw new ConfigError('配置在保存期间发生了变化,这次没有覆盖它。请重试。');
|
|
88
89
|
await (options.replace ?? rename)(temporary, file);
|
|
89
90
|
committed = true;
|
|
90
91
|
}
|
|
@@ -94,7 +95,7 @@ export async function commitConfig(file, data, expected, store, values, options
|
|
|
94
95
|
await removeFile(temporary);
|
|
95
96
|
}
|
|
96
97
|
catch {
|
|
97
|
-
warn(
|
|
98
|
+
warn(`临时配置没有清理掉:${path.basename(temporary)}`);
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
if (created && !committed) {
|
|
@@ -102,14 +103,14 @@ export async function commitConfig(file, data, expected, store, values, options
|
|
|
102
103
|
store.remove(id);
|
|
103
104
|
}
|
|
104
105
|
catch {
|
|
105
|
-
warn(
|
|
106
|
+
warn(`配置没有切换,新凭据也未能清理。引用 ID:${id}`);
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
try {
|
|
109
110
|
await removeFile(lockPath);
|
|
110
111
|
}
|
|
111
112
|
catch {
|
|
112
|
-
warn('
|
|
113
|
+
warn('配置写入锁没有清理掉。请确认没有向导在运行,再删除 .setup.lock。');
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
if (old?.backend === 'keyring') {
|
|
@@ -117,11 +118,11 @@ export async function commitConfig(file, data, expected, store, values, options
|
|
|
117
118
|
store.remove(old.id);
|
|
118
119
|
}
|
|
119
120
|
catch {
|
|
120
|
-
warn(
|
|
121
|
+
warn(`配置已保存,但旧凭据没有清理掉。引用 ID:${old.id}`);
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
else if (old?.backend === 'system') {
|
|
124
|
-
warn('
|
|
125
|
+
warn('旧版原型的旧凭据仍然保留,由旧版工具继续管理,避免两个实现互相误删。');
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
export async function wizard(file, options = {}) {
|
|
@@ -129,11 +130,14 @@ export async function wizard(file, options = {}) {
|
|
|
129
130
|
const before = await snapshot(file), data = readRaw(before);
|
|
130
131
|
const { out } = prompts;
|
|
131
132
|
out(`配置路径:${file}`);
|
|
132
|
-
out('
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
out('Token、webhook 和签名密钥都不会回显,将保存到 Windows Credential Manager / macOS Keychain;配置文件只保留引用。');
|
|
134
|
+
const telegramEnabled = data.telegram?.enabled !== false;
|
|
135
|
+
const feishuEnabled = data.feishu?.enabled !== false;
|
|
136
|
+
const current = before === null ? ''
|
|
137
|
+
: telegramEnabled && feishuEnabled ? '3'
|
|
138
|
+
: telegramEnabled ? '1' : feishuEnabled ? '2' : '';
|
|
139
|
+
const entered = await prompts.text(`选择通知渠道:1 Telegram,2 飞书,3 两者${current ? ` [${current}]` : ''}(输入其他内容取消):`);
|
|
140
|
+
const selection = entered || current;
|
|
137
141
|
if (!['1', '2', '3'].includes(selection))
|
|
138
142
|
return false;
|
|
139
143
|
const store = options.store ?? nativeStore();
|
|
@@ -147,13 +151,13 @@ export async function wizard(file, options = {}) {
|
|
|
147
151
|
previous = existing;
|
|
148
152
|
}
|
|
149
153
|
catch {
|
|
150
|
-
out('
|
|
151
|
-
if (!yes(await prompts.text('
|
|
154
|
+
out('当前凭据暂时读不到,可能是条目缺失、凭据库锁定或权限不足。');
|
|
155
|
+
if (!yes(await prompts.text('要重新输入所选渠道的凭据吗?[y/N] ')))
|
|
152
156
|
return false;
|
|
153
157
|
}
|
|
154
158
|
}
|
|
155
159
|
else if (reference?.backend === 'system')
|
|
156
|
-
out('
|
|
160
|
+
out('检测到旧版原型保存的凭据。本次会重新输入并保存为 Node 版凭据,旧凭据保持不变。');
|
|
157
161
|
data.telegram ??= {};
|
|
158
162
|
data.feishu ??= {};
|
|
159
163
|
data.telegram.enabled = selection !== '2';
|
|
@@ -161,11 +165,12 @@ export async function wizard(file, options = {}) {
|
|
|
161
165
|
const values = {};
|
|
162
166
|
const readValue = async (label, key, required) => {
|
|
163
167
|
while (true) {
|
|
164
|
-
const hint = previous[key] ? '
|
|
165
|
-
const
|
|
168
|
+
const hint = previous[key] ? '留空保留当前值' : '不会从环境变量自动填充';
|
|
169
|
+
const optional = required ? '' : '可选;';
|
|
170
|
+
const answer = await prompts.secret(`${label}(隐藏输入,不回显;${optional}${hint}${required ? '' : ';输入 - 清除'}):`);
|
|
166
171
|
const value = !required && answer === '-' ? '' : answer || previous[key] || '';
|
|
167
172
|
if (required && !value) {
|
|
168
|
-
out('
|
|
173
|
+
out('这一项不能为空,请重新输入;按 Ctrl+C 可退出。');
|
|
169
174
|
continue;
|
|
170
175
|
}
|
|
171
176
|
try {
|
|
@@ -175,15 +180,15 @@ export async function wizard(file, options = {}) {
|
|
|
175
180
|
return value;
|
|
176
181
|
}
|
|
177
182
|
catch {
|
|
178
|
-
out('
|
|
183
|
+
out('格式不正确,请重新输入(输入内容不会显示)。');
|
|
179
184
|
}
|
|
180
185
|
}
|
|
181
186
|
};
|
|
182
187
|
if (data.telegram.enabled) {
|
|
183
188
|
values.telegram_token = await readValue('Telegram Bot Token', 'telegram_token', true);
|
|
184
|
-
out('
|
|
189
|
+
out('请先让接收人给 Bot 发一条消息,然后填写数字 chat_id;这里不会自动获取或配对接收人。');
|
|
185
190
|
while (true) {
|
|
186
|
-
const entered = await prompts.text('接收人 chat_id
|
|
191
|
+
const entered = await prompts.text('接收人 chat_id,多个用逗号分隔(留空保留现有名单):');
|
|
187
192
|
const ids = entered ? entered.split(',').map(item => item.trim()) : data.telegram.chat_ids ?? [];
|
|
188
193
|
try {
|
|
189
194
|
if (!ids.length)
|
|
@@ -193,58 +198,84 @@ export async function wizard(file, options = {}) {
|
|
|
193
198
|
break;
|
|
194
199
|
}
|
|
195
200
|
catch {
|
|
196
|
-
out('
|
|
201
|
+
out('请填写至少一个有效的数字 chat_id;按 Ctrl+C 可退出。');
|
|
197
202
|
}
|
|
198
203
|
}
|
|
199
204
|
}
|
|
200
205
|
if (data.feishu.enabled) {
|
|
201
206
|
values.feishu_webhook = await readValue('飞书 webhook', 'feishu_webhook', true);
|
|
202
|
-
values.feishu_secret = await readValue('
|
|
207
|
+
values.feishu_secret = await readValue('飞书签名密钥', 'feishu_secret', false);
|
|
203
208
|
}
|
|
204
209
|
encodeBundle(values);
|
|
205
210
|
const preview = { ...data, credentials: { backend: 'keyring', id: '0'.repeat(32) } };
|
|
206
211
|
parseConfig(preview, {}, () => values);
|
|
207
212
|
const env = options.env ?? process.env;
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
const channels = [];
|
|
214
|
+
if (data.telegram.enabled)
|
|
215
|
+
channels.push(`Telegram(${data.telegram.chat_ids.length} 个接收人)`);
|
|
216
|
+
if (data.feishu.enabled)
|
|
217
|
+
channels.push('飞书');
|
|
218
|
+
out(`将启用:${channels.join('、')}`);
|
|
219
|
+
out('环境变量的优先级更高,空值也会覆盖这里的设置;测试通知使用最终生效的配置。');
|
|
210
220
|
const overrides = [data.telegram.token_env ?? 'TELEGRAM_BOT_TOKEN', data.feishu.webhook_env ?? 'FEISHU_WEBHOOK',
|
|
211
221
|
data.feishu.secret_env ?? 'FEISHU_SECRET', 'TELEGRAM_CHAT_ID', 'TELEGRAM_CHAT_IDS'].filter(name => Object.hasOwn(env, name));
|
|
212
222
|
if (overrides.length)
|
|
213
|
-
out('
|
|
223
|
+
out('以下环境变量正在覆盖配置:' + overrides.join('、'));
|
|
214
224
|
if (reference?.backend === 'keyring')
|
|
215
|
-
out('
|
|
216
|
-
out('
|
|
217
|
-
|
|
225
|
+
out('保存成功后会删除旧凭据;如果其他配置也引用了同一个凭据,需要一并更新。');
|
|
226
|
+
out('没有选中的渠道会被停用;新凭据只用于本次选中的渠道。');
|
|
227
|
+
for (const line of options.notes ?? [])
|
|
228
|
+
out(line);
|
|
229
|
+
const actions = ['保存配置和系统凭据'];
|
|
230
|
+
if (options.testSend)
|
|
231
|
+
actions.push('发送固定内容的测试通知');
|
|
232
|
+
if (options.afterSave)
|
|
233
|
+
actions.push('安装 Claude Code 通知 hooks');
|
|
234
|
+
if (!defaultYes(await prompts.text(`将${actions.join('、')}。继续?[Y/n] `)))
|
|
218
235
|
return false;
|
|
219
236
|
await commitConfig(file, data, before, store, values, { warn: out });
|
|
220
237
|
out('配置和系统凭据已保存。');
|
|
221
|
-
if (options.testSend
|
|
238
|
+
if (options.testSend) {
|
|
222
239
|
try {
|
|
223
240
|
const effective = parseConfig(preview, env, () => values);
|
|
224
241
|
const ok = await options.testSend(effective);
|
|
225
|
-
out(ok ? '测试通知发送成功。' : '
|
|
242
|
+
out(ok ? '测试通知发送成功。' : '配置已保存。测试通知没有发送成功,请运行 dev-notify doctor 检查。');
|
|
226
243
|
}
|
|
227
244
|
catch {
|
|
228
|
-
out('
|
|
245
|
+
out('配置已保存。测试通知没有发送成功,可能是环境变量覆盖或网络问题;请运行 dev-notify doctor 检查。');
|
|
229
246
|
}
|
|
230
247
|
}
|
|
231
248
|
else
|
|
232
|
-
out('
|
|
249
|
+
out('已跳过测试通知。');
|
|
250
|
+
if (options.afterSave) {
|
|
251
|
+
try {
|
|
252
|
+
await options.afterSave();
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
out(`配置已保存。Claude Code hooks 没有安装完成:${error instanceof ConfigError ? error.message : '请运行 integration claude-code status 检查'}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
if (ready(parseConfig(preview, env, () => values), 'telegram')) {
|
|
260
|
+
out('如需在 Telegram 中回复 Claude,请运行:dev-notify reply setup');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch { /* 环境覆盖无效时上方测试分支已报告。 */ }
|
|
233
264
|
return true;
|
|
234
265
|
}
|
|
235
266
|
export async function clearCredentials(file, prompts, store) {
|
|
236
267
|
const before = await snapshot(file), data = readRaw(before);
|
|
237
268
|
const reference = data.credentials;
|
|
238
269
|
if (!reference) {
|
|
239
|
-
prompts.out('
|
|
270
|
+
prompts.out('当前配置没有系统凭据引用,无需清理。');
|
|
240
271
|
return true;
|
|
241
272
|
}
|
|
242
273
|
if (reference.backend !== 'keyring')
|
|
243
|
-
throw new ConfigError('
|
|
244
|
-
prompts.out('
|
|
245
|
-
if (!yes(await prompts.text('
|
|
274
|
+
throw new ConfigError('这是旧版原型保存的凭据;Node 版不会跨实现删除,请用原工具处理。');
|
|
275
|
+
prompts.out('将删除当前配置引用的系统凭据,并从配置中移除引用。环境变量不受影响;如果其他配置共享同一个凭据,它们也会失效。');
|
|
276
|
+
if (!yes(await prompts.text('确认清除吗?[y/N] ')))
|
|
246
277
|
return false;
|
|
247
278
|
await commitConfig(file, data, before, store ?? nativeStore(), null, { warn: prompts.out });
|
|
248
|
-
prompts.out('
|
|
279
|
+
prompts.out('凭据引用已移除。如果系统条目删除失败,上方会显示提示。');
|
|
249
280
|
return true;
|
|
250
281
|
}
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { ConfigError } from './config.js';
|
|
3
|
+
import { terminalPrompts } from './prompts.js';
|
|
4
|
+
const NAME = '@quecue/dev-notify';
|
|
5
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(alpha|beta|rc)\.(0|[1-9]\d*))?$/;
|
|
6
|
+
function npmRunner(args, capture) {
|
|
7
|
+
// Windows 的 npm 是 .cmd;Node 不能直接 spawn,需要经过 shell。参数均为固定值。
|
|
8
|
+
const result = spawnSync('npm', args, {
|
|
9
|
+
encoding: 'utf8',
|
|
10
|
+
stdio: capture ? 'pipe' : 'inherit',
|
|
11
|
+
timeout: capture ? 30000 : 300000,
|
|
12
|
+
shell: process.platform === 'win32',
|
|
13
|
+
});
|
|
14
|
+
if (result.error)
|
|
15
|
+
throw new ConfigError('无法运行 npm。请确认已安装 npm,并且它在 PATH 中。');
|
|
16
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
17
|
+
}
|
|
18
|
+
async function fetchLatest() {
|
|
19
|
+
let response;
|
|
20
|
+
try {
|
|
21
|
+
response = await fetch(`https://registry.npmjs.org/${NAME.replace('/', '%2F')}/latest?t=${Date.now()}`, {
|
|
22
|
+
redirect: 'error',
|
|
23
|
+
signal: AbortSignal.timeout(10000),
|
|
24
|
+
headers: { 'Cache-Control': 'no-cache, no-store', Accept: 'application/json' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new ConfigError('无法检查最新版本。请检查网络后重试。');
|
|
29
|
+
}
|
|
30
|
+
if (!response.ok)
|
|
31
|
+
throw new ConfigError(`无法检查最新版本(HTTP ${response.status})。请检查网络后重试。`);
|
|
32
|
+
let version;
|
|
33
|
+
try {
|
|
34
|
+
version = (await response.json())?.version;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new ConfigError('npm registry 返回的版本信息无法解析。');
|
|
38
|
+
}
|
|
39
|
+
if (typeof version !== 'string' || !VERSION.test(version))
|
|
40
|
+
throw new ConfigError('npm registry 返回的版本号格式不受支持。');
|
|
41
|
+
return version;
|
|
42
|
+
}
|
|
43
|
+
function parts(version) {
|
|
44
|
+
const match = VERSION.exec(version);
|
|
45
|
+
if (!match)
|
|
46
|
+
throw new ConfigError('版本号格式不受支持。');
|
|
47
|
+
const rank = match[4] === 'alpha' ? 1 : match[4] === 'beta' ? 2 : match[4] === 'rc' ? 3 : 4;
|
|
48
|
+
return [Number(match[1]), Number(match[2]), Number(match[3]), rank, Number(match[5] ?? 0)];
|
|
49
|
+
}
|
|
50
|
+
export function compareVersions(left, right) {
|
|
51
|
+
const a = parts(left), b = parts(right);
|
|
52
|
+
for (let i = 0; i < a.length; i++)
|
|
53
|
+
if (a[i] !== b[i])
|
|
54
|
+
return a[i] > b[i] ? 1 : -1;
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
async function confirmUpdate(latest, options) {
|
|
58
|
+
if (options.yes)
|
|
59
|
+
return true;
|
|
60
|
+
if (!options.prompts && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
61
|
+
throw new ConfigError('更新需要在交互终端中确认;非交互场景请使用 dev-notify update --yes。');
|
|
62
|
+
}
|
|
63
|
+
const prompts = options.prompts ?? terminalPrompts();
|
|
64
|
+
return /^(y|yes)$/i.test(await prompts.text(`更新到 ${latest}?[y/N] `));
|
|
65
|
+
}
|
|
66
|
+
export async function updatePackage(current, options) {
|
|
67
|
+
if (!VERSION.test(current))
|
|
68
|
+
throw new ConfigError('当前版本号格式不受支持,无法检查更新。');
|
|
69
|
+
const latest = await (options.latest ?? fetchLatest)();
|
|
70
|
+
if (!VERSION.test(latest))
|
|
71
|
+
throw new ConfigError('npm registry 返回的版本号格式不受支持。');
|
|
72
|
+
if (compareVersions(latest, current) <= 0) {
|
|
73
|
+
options.out(`已是最新版本 ${current}。`);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
options.out(`当前版本 ${current},npm 最新版本 ${latest}。`);
|
|
77
|
+
if (options.check) {
|
|
78
|
+
options.out('运行 dev-notify update 更新。');
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
if (!await confirmUpdate(latest, options)) {
|
|
82
|
+
options.out('已取消,没有更新。');
|
|
83
|
+
return 130;
|
|
84
|
+
}
|
|
85
|
+
options.out(`运行 npm install -g ${NAME}@${latest} ...`);
|
|
86
|
+
const result = (options.runner ?? npmRunner)(['install', '-g', `${NAME}@${latest}`], false);
|
|
87
|
+
if (result.status !== 0)
|
|
88
|
+
throw new ConfigError(`更新没有完成。请检查 npm 权限,或手动运行 npm install -g ${NAME}@latest。`);
|
|
89
|
+
options.out(`已更新到 ${latest}。请重新打开终端后运行 dev-notify --version 确认。`);
|
|
90
|
+
options.out('如有 Claude Code hooks,请运行 dev-notify integration claude-code install;如使用远程回复,请重启桥接。');
|
|
91
|
+
return 0;
|
|
92
|
+
}
|